Decidir quando e como quebrar uma tela em componentes menores, distinguindo componentes "burros" (apresentação) de "inteligentes" (com lógica).
Definir contratos de componente com defineProps (tipado, com required/default/validator) e defineEmits, incluindo v-model customizado com defineModel() e a forma clássica.
Usar provide/inject para dados compartilhados em profundidade e controlar atributos fallthrough com defineOptions({ inheritAttrs: false }).
Aplicar slots (padrão, nomeados e com escopo) para criar componentes de layout reutilizáveis.
Extrair lógica reativa reutilizável em composables (use*) e explicar por que isso substitui mixins.
Configurar rotas aninhadas, rotas nomeadas, meta, navigation guards e sincronizar filtros com query strings na URL.
Construir formulários validados com v-form, listar dados com v-data-table e usar diálogos de confirmação, tabs, menus e skeleton loaders do Vuetify.
[ ] UniEventos da Aula 04 rodando: Vuetify instalado, tema configurado, rotas home, evento-detalhe, sobre, nao-encontrado funcionando.
[ ] Marco 1 do projeto alcançado (ou em fase final de conclusão).
[ ] Domínio confortável de <script setup>, defineProps/defineEmits básicos (vistos rapidamente na Aula 02), computed, onMounted.
Na Aula 04 você transformou o UniEventos em uma SPA navegável: Vuetify instalado com tema institucional, v-app-bar/v-navigation-drawer/v-main no lugar, quatro rotas registradas e as views migradas para componentes Vuetify. O que ficou pendente é a organização interna: cada view ainda concentra marcação, dados e lógica no mesmo arquivo.
Hoje o UniEventos fica modular. Em vez de views monolíticas com tudo dentro, cada pedaço de interface vira um componente com contrato próprio — props de entrada, eventos de saída, slots para o que varia —, e a lógica reativa repetida sai das views para composables.
Na segunda metade da aula a área administrativa entra em cena, com rotas aninhadas, navigation guards, formulários validados com v-form e uma v-data-table de eventos. É o esqueleto que a Aula 06 vai conectar a uma API de verdade.
Até aqui, cada view do UniEventos (HomeView, EventoDetalheView) concentrava template, lógica e estilo em um único arquivo. Isso funciona em uma tela pequena, mas cresce mal: a HomeView já mistura busca, filtro, grid de cards e lógica de carregamento — daqui a duas aulas, com formulário de cadastro e tabela administrativa, o arquivo viraria ilegível.
Componentizar é dividir a interface em peças menores, cada uma com uma responsabilidade única e um contrato explícito de entrada (props) e saída (emits). O benefício não é só organização de arquivo — é reuso (o mesmo EventoCard aparece na home, na busca e na área administrativa) e testabilidade (um componente pequeno é mais fácil de entender isoladamente).
Não existe regra rígida, mas alguns sinais indicam que é hora de extrair um componente:
O mesmo trecho de template se repete em duas ou mais telas (ex.: o card de evento).
Um bloco do template tem lógica própria que não interessa ao componente pai (ex.: a lógica de validação de um campo de formulário).
O arquivo passou de ~150–200 linhas e virou difícil de escanear visualmente.
Você consegue nomear o pedaço com um substantivo claro (EventoCard, FiltroEventos, CabecalhoApp) — se não consegue nomear, talvez não seja um componente coerente ainda.
⚠️ Atenção: granularidade excessiva também é problema. Um projeto com componentes de 5 linhas para cada <span> cria uma "sopa de componentes" difícil de navegar. Componentize quando há repetição ou responsabilidade clara — não por dogma.
Uma distinção útil (não uma regra do Vue, mas um padrão de arquitetura comum em SPAs):
Componente burro (presentational / dumb) — só recebe dados via props e emite eventos. Não sabe de onde vêm os dados nem o que acontece depois do evento. Fácil de reutilizar e testar. Exemplo: EventoCard, que recebe um objeto evento e emite @inscrever.
Componente inteligente (container / smart) — busca dados, decide o que fazer com eventos emitidos pelos filhos, conversa com store/API. Exemplo: HomeView, que carrega a lista de eventos e passa cada um para um EventoCard.
Essa separação evita que a lógica de negócio (como buscar dados, como filtrar) fique espalhada em componentes visuais pequenos — o que dificultaria trocar, por exemplo, a fonte de dados sem tocar em uma dezena de arquivos.
type habilita checagem em tempo de desenvolvimento — o Vue avisa no console se você passar um tipo errado.
required: true faz o Vue emitir um aviso se a prop não for passada.
default define um valor quando a prop não é informada (obrigatório para props opcionais que não são required).
validator é uma função que recebe o valor e retorna true/false — útil para restringir um número a uma faixa, ou uma string a um conjunto de valores permitidos (enum informal).
💡 Dica: props são somente leitura dentro do componente filho — nunca faça props.evento = outraCoisa. Se o filho precisa "mudar" algo que veio do pai, ele deve emitir um evento pedindo a mudança, e é o pai quem decide se atende.
defineEmits declarado como objeto (em vez de array de strings) permite validar o payload de cada evento — assim como defineProps valida entradas, isso valida saídas. O componente pai escuta o evento normalmente:
Você já usa v-model em v-text-field e v-dialog — isso é possível porque esses componentes implementam o contrato de v-model. Você pode implementar o mesmo contrato nos seus próprios componentes, de duas formas.
defineModel() cria automaticamente uma prop modelValue e um evento update:modelValue por baixo dos panos, expondo tudo como uma única variável reativa (modelo) que você lê e escreve como se fosse um ref comum. É a forma recomendada para código novo.
As duas formas produzem exatamente o mesmo comportamento externo — <CampoBusca v-model="termoBusca" /> funciona igual nos dois casos. defineModel() é mais curto e é o padrão desta trilha daqui em diante, mas você vai encontrar a forma clássica em muito código existente (inclusive em bibliotecas), então precisa reconhecê-la.
🔎 Por baixo do capô:v-model="x" em um componente é açúcar sintático para :model-value="x" @update:model-value="x = $event". É exatamente o mesmo mecanismo de prop + evento que você já usa manualmente — só que com uma sintaxe mais curta, reconhecida pelo compilador do Vue.
Passar props por 3 ou 4 níveis de componentes só para chegar a um neto profundo (prop drilling) é doloroso de manter. Para dados amplamente compartilhados — tema, usuário logado, configuração global —, o Vue oferece provide/inject:
<!-- src/components/PainelPerfil.vue (qualquer nível abaixo de App.vue) -->
<script setup>
import { inject } from 'vue'
const usuarioLogado = inject('usuarioLogado')
</script>
<template>
<span>Olá, {{ usuarioLogado.nome }}</span>
</template>
inject encontra o valor mais próximo fornecido por um ancestral, não importa quantos níveis de componentes existam entre eles. Não é um substituto para comunicação local (props/emits continuam sendo a opção certa entre pai e filho diretos) — é uma ferramenta específica para dados "ambientais". Na Aula 06, o Pinia vai resolver a maior parte desses casos de forma mais estruturada; provide/inject ainda é útil para configuração de componentes de biblioteca (é assim, inclusive, que o próprio Vuetify propaga o tema).
Quando você passa um atributo a um componente que não está declarado como prop, o Vue aplica automaticamente esse atributo à raiz do template do componente — isso se chama fallthrough:
Se EventoCard não declara class nem data-testid como props, o Vue aplica os dois diretamente no elemento raiz do template de EventoCard (por exemplo, no <v-card>). Isso é conveniente na maioria dos casos — mas quando o componente tem múltiplos elementos raiz, ou quando você quer redirecionar o atributo para um elemento interno específico (não o raiz), use:
defineOptions({ inheritAttrs: false }) desliga o comportamento automático; v-bind="$attrs" aplica manualmente todos os atributos não declarados como props no elemento que você escolher.
🔬 Investigue
Renderize <EventoCard :evento="evento" class="destaque" data-testid="card-evento" /> sem declarar class nem data-testid como props em EventoCard. Abra o DevTools, aba Elements, e inspecione o <v-card> renderizado: a classe destaque e o atributo data-testid apareceram nele, mesmo sem você ter escrito nada a mais no template do componente — esse é o fallthrough automático. Agora adicione defineOptions({ inheritAttrs: false }) ao EventoCard, sem adicionar v-bind="$attrs" em lugar nenhum, e inspecione de novo: para onde os atributos foram?
Props resolvem "que dados entram". Slots resolvem "que conteúdo/template entra" — permitem que um componente pai injete HTML/componentes dentro de um "buraco" definido pelo componente filho.
<CartaoBase>
<template #titulo>Semana Acadêmica</template>
Conteúdo do corpo do card, vai para o slot padrão.
<template #acoes>
<v-btn color="primary">Inscrever-se</v-btn>
</template>
</CartaoBase>
#titulo é o atalho para v-slot:titulo. Um slot sem name é chamado de slot padrão (default), e recebe qualquer conteúdo que não esteja explicitamente marcado com <template #algumNome>.
Às vezes o componente filho tem dados que o pai precisa usar dentro do conteúdo injetado. Um slot com escopo passa dados do filho para o template do pai:
O componente EventoLista controla a iteração (v-for) e a lógica auxiliar (formatarData), mas delega ao componente pai como cada item é desenhado. Isso é poderoso: o mesmo EventoLista pode ser reaproveitado em uma tela que mostra cards e em outra que mostra uma tabela — só o slot #item muda.
Um composable é uma função que usa a Composition API (ref, computed, watch, onMounted etc.) para encapsular um pedaço de lógica reativa reutilizável, seguindo a convenção de nome use*.
Cada chamada de useEventos() cria seu próprio estado isolado (as variáveis ref são criadas de novo a cada chamada) — diferente de uma store Pinia, que é compartilhada globalmente (veremos essa distinção com clareza na Aula 06).
Antes da Composition API, o Vue 2 usava mixins para reutilizar lógica entre componentes: um objeto com data, methods, computed que era "misturado" ao componente. O problema era que, ao usar dois ou mais mixins no mesmo componente, não dava para saber de onde vinha cada propriedade — se data.carregando veio do mixin A ou do mixin B era invisível no template, e colisões de nome se sobrescreviam silenciosamente.
Composables resolvem isso porque tudo é explícito: você importa a função, chama, e desestrutura exatamente o que quer usar, sob o nome que quiser:
Não há mágica de mesclagem por trás — é só uma função JavaScript comum retornando um objeto. Essa clareza de origem é a razão pela qual a comunidade Vue abandonou mixins como padrão recomendado.
🧠 Você sabia?
A Composition API do Vue 3 (2020) foi diretamente influenciada pelos React Hooks, lançados em 2018 — ambos resolvem o mesmo problema (reutilizar lógica com estado sem herança nem mixins) com uma ideia parecida: funções que encapsulam ref/state, computed/useMemo, watch/useEffect. A diferença prática que mais ajuda no dia a dia: hooks do React têm regras rígidas de ordem de chamada (não pode chamar dentro de if), enquanto composables do Vue são só funções JavaScript comuns — sem essa restrição, porque a reatividade do Vue não depende da ordem em que os hooks foram chamados na renderização anterior.
Uma área administrativa tem uma URL-base (/admin) com sub-telas (/admin/eventos, /admin/eventos/novo). Em vez de repetir /admin em cada rota, use children:
Esse aninhamento de RouterView dentro de RouterView é o mesmo padrão Composite que vimos na Aula 04 aplicado à navegação: cada nível de rota tem seu próprio "slot" de renderização.
Guards são funções que rodam antes (ou depois) de uma navegação, podendo permitir, bloquear ou redirecionar.
beforeEach — guard global, roda em toda navegação:
JavaScript
// src/router/index.js (trecho, após criar o router)router.beforeEach((to,from)=>{document.title=to.meta.titulo?`${to.meta.titulo} · UniEventos`:'UniEventos'constautenticado=false// substituiremos por estado real com Pinia na Aula 06if(to.meta.requerAutenticacao&&!autenticado){return{name:'home'}// redireciona}// retornar undefined/true permite a navegação})
beforeEnter — guard por rota, só roda ao entrar naquela rota específica:
Uma prática comum e muito útil: refletir o estado dos filtros de busca na URL, para que o usuário possa compartilhar/recarregar a página sem perder o filtro aplicado.
Vue SFC
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const rota = useRoute()
const router = useRouter()
// inicializa o filtro a partir da query string, se existir
const categoriaFiltro = ref(rota.query.categoria ?? 'Todas')
// sempre que o filtro mudar, atualiza a URL (sem recarregar a página)
watch(categoriaFiltro, (novoValor) => {
router.push({ query: { ...rota.query, categoria: novoValor } })
})
</script>
Com isso, /eventos?categoria=Minicurso carrega a tela já filtrada — útil para compartilhar um link de busca específica, e para o botão "voltar" do navegador restaurar o filtro anterior.
Por padrão, ao navegar entre rotas o Vue Router mantém a posição de rolagem atual. Para voltar ao topo em cada navegação (comportamento mais comum em SPAs de conteúdo):
JavaScript
// src/router/index.js (trecho, dentro de createRouter)constrouter=createRouter({history:createWebHistory(import.meta.env.BASE_URL),scrollBehavior(to,from,savedPosition){if(savedPosition)returnsavedPosition// navegação por botão voltar/avançarreturn{top:0}},routes:[/* ... */],})
Nem toda rota deve usar o mesmo App.vue. A área administrativa, por exemplo, pode ter um layout próprio (sem o app-bar público). Uma forma simples é usar rotas aninhadas com um componente de layout diferente para cada seção — exatamente a estrutura de AdminLayoutView.vue que criamos acima. Cada "família" de rotas aponta para seu próprio layout, e cada layout tem seu próprio <RouterView /> interno.
<script setup>
import { ref } from 'vue'
const formRef = ref(null)
const titulo = ref('')
const vagas = ref(null)
const regrasTitulo = [
(v) => !!v || 'O título é obrigatório',
(v) => (v && v.length >= 5) || 'O título precisa ter ao menos 5 caracteres',
]
const regrasVagas = [
(v) => !!v || 'Informe o número de vagas',
(v) => (v > 0) || 'O número de vagas deve ser positivo',
]
async function salvar() {
const { valid } = await formRef.value.validate()
if (!valid) return
console.log('Formulário válido — dados prontos para envio:', { titulo: titulo.value, vagas: vagas.value })
}
</script>
<template>
<v-form ref="formRef" @submit.prevent="salvar">
<v-text-field v-model="titulo" label="Título do evento" :rules="regrasTitulo" />
<v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" />
<v-btn type="submit" color="primary">Salvar</v-btn>
</v-form>
</template>
Nesta seção o objetivo é validação — o envio real (para uma API ou para uma store) aparece completo no Mão na massa desta aula, e de novo, com Axios, na Aula 06.
rules é um array de funções que recebem o valor atual do campo e retornam true (válido) ou uma string (mensagem de erro exibida abaixo do campo). Chamar formRef.value.validate() executa todas as regras de todos os campos do formulário de uma vez e retorna { valid, errors }.
⚠️ Atenção: no Vuetify 4, se você usa o slot com escopo do v-form (<v-form v-slot="{ isValid }">) para acessar o estado de validação diretamente no template, essas variáveis de slot não são mais refs — não use .value nelas dentro do template. Compare:
```vue
Salvar
Salvar
```
Se você copiar um exemplo antigo com .value dentro do template do v-form, o botão nunca habilita — isValid deixou de ser um objeto ref e passou a ser o valor puro.
v-data-table já traz ordenação por coluna (clicando no cabeçalho), paginação e busca (via prop search, cruzada contra todos os campos dos itens) prontos, sem código adicional. O slot nomeado #item.acoes — repare no padrão item.<chave-da-coluna> — permite customizar completamente o conteúdo de uma coluna, exatamente com a técnica de slot com escopo que vimos na §2.
v-dialog de confirmação, v-tabs, v-menu, v-skeleton-loader, v-pagination¶
Vue SFC
<!-- diálogo de confirmação reutilizável -->
<v-dialog v-model="dialogoAberto" max-width="400" persistent>
<v-card title="Confirmar exclusão" text="Esta ação não pode ser desfeita.">
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialogoAberto = false">Cancelar</v-btn>
<v-btn color="error" variant="flat" @click="confirmarExclusao">Excluir</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
O slot #activator do v-menu é outro exemplo de slot com escopo: ele entrega propsAtivador, um conjunto de listeners/atributos que você precisa espalhar (v-bind) no elemento que deve abrir o menu ao ser clicado.
v-skeleton-loader substitui o v-progress-circular genérico quando você quer que o "estado de carregando" já sugira a forma do conteúdo final (cards cinza pulsando no lugar dos cards reais) — uma técnica de percepção de performance bastante usada em produção.
🧩 Padrão de projeto em uso — Composite e Template Method¶
Composite aparece de novo hoje, agora na composição de componentes de layout: CartaoBase não sabe o que vai dentro dele — apenas define a "moldura" (v-card com título, corpo e ações), e quem usa o componente decide o conteúdo via slots. Isso é o mesmo princípio da árvore de componentes da Aula 04, aplicado deliberadamente ao design de um componente reutilizável.
Template Method é um padrão comportamental em que uma classe (ou, aqui, um componente) define o esqueleto de um algoritmo ou de uma estrutura, deixando etapas específicas para serem preenchidas por quem o usa. Um slot com escopo — como o #item de EventoLista — é exatamente isso: o componente controla o "algoritmo" (iterar sobre a lista, aplicar filtro), mas delega ao chamador a etapa de "como desenhar cada item". A estrutura geral é fixa; o passo variável é injetado de fora.
💻 Mão na massa — refatorando o UniEventos em componentes¶
⚠️ Atenção
O vite-plugin-vuetify faz auto-import só dos componentes do Vuetify (v-card, v-row…). Os seus componentes, mesmo no mesmo diretório, não são registrados automaticamente pelo create-vue: usar <EventoCard /> sem importar produz o aviso Failed to resolve component: EventoCard no console e um espaço em branco na tela. Todo componente autoral que você usar em um <template> precisa de um import no <script setup> do arquivo que o usa.
defineModel('busca', ...) e defineModel('categoria', ...) são a forma de defineModel() para múltiplosv-models no mesmo componente — cada nome vira um par prop/evento independente:
Passo 7 — reescrever HomeView.vue usando os componentes e o composable¶
Vue SFC
<!-- src/views/HomeView.vue -->
<script setup>
import { useEventos } from '../composables/useEventos'
import FiltroEventos from '../components/FiltroEventos.vue'
import EventoLista from '../components/EventoLista.vue'
const { carregando, categoriaFiltro, busca, eventosFiltrados } = useEventos()
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Eventos disponíveis</h1>
<FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />
<div v-if="carregando" class="d-flex justify-center pa-8">
<v-skeleton-loader type="card" v-for="n in 3" :key="n" class="mb-4" />
</div>
<v-alert
v-else-if="eventosFiltrados.length === 0"
type="info"
variant="tonal"
title="Nenhum evento encontrado"
>
Tente ajustar os filtros de categoria ou o termo de busca.
</v-alert>
<EventoLista v-else :eventos="eventosFiltrados" />
</v-container>
</template>
Compare este arquivo com o HomeView.vue da Aula 04: a lógica de busca/filtro/carregamento saiu para o composable useEventos, o grid de cards virou EventoLista, e os campos de filtro viraram FiltroEventos. A view agora só orquestra — é um bom exemplo de componente "inteligente" fino, delegando apresentação aos filhos.
A área administrativa que começa aqui altera a lista de eventos: exclui, cria e edita. O arquivo criado na Aula 04 exporta um array JavaScript comum — e o Vue não observa arrays comuns. Se a área administrativa mexer nele como está, o dado até muda na memória, mas a v-data-table, o contador do AdminHomeView e o chip do v-app-bar continuam mostrando o valor antigo: o CRUD "não funciona" sem nenhum erro no console. Envolva o array em reactive() antes de seguir:
JavaScript
// src/data/eventos.js — agora reativoimport{reactive}from'vue'exportconsteventos=reactive([{id:1,titulo:'Semana Acadêmica de Computação',descricao:'Palestras e minicursos sobre tendências em tecnologia.',categoria:'palestra',dataHora:'2030-09-29T19:00:00',local:'Auditório Central',vagas:40,imagemUrl:'https://picsum.photos/seed/evento1/600/300'},// … os outros sete eventos, sem alteração])
⚠️ Atenção
Continue mutando o array no lugar (push, splice, Object.assign), nunca reatribuindo (eventos = [...]) — reactive() protege o conteúdo, não a variável, e a reatribuição quebraria a ligação com todas as telas de uma vez (é exatamente o bug do item A4 da Aula 02). Este arquivo é uma "store caseira": funciona bem para uma maquete, e na Aula 06 ele dá lugar a uma store Pinia de verdade.
Com npm run dev rodando, percorra os dois lados da aplicação:
Público — a HomeView mostra os oito cards vindos de EventoLista/EventoCard; digitar no FiltroEventos reduz a lista e a query string da URL acompanha (?busca=vue); recarregar a página com a query string preservada devolve a mesma lista filtrada.
Administrativo — acesse /admin: a v-data-table lista os mesmos oito eventos. Clique em "Novo evento", salve um evento válido e confira que ele aparece na tabela imediatamente, que o contador do AdminHomeView sobe de 8 para 9 e que o evento novo também aparece na Home pública.
Exclusão — exclua esse evento pelo diálogo de confirmação: a linha some da tabela na hora e o contador volta a 8.
Guard de saída — comece a editar um evento, altere um campo e clique em "Cancelar": o onBeforeRouteLeave pergunta se você quer mesmo sair.
Resultado esperado: os quatro itens acima passam sem recarregar a página. Se a tabela e o contador não mudarem depois de criar ou excluir, o reactive() do Passo 8 não foi aplicado — é o sintoma exato descrito lá.
A1. Preveja a saída no console: EventoCard declara defineProps({ evento: { type: Object, required: true } }), e o componente pai usa <EventoCard /> sem passar a prop evento.
Resultado esperado: um aviso no console ([Vue warn]: Missing required prop: "evento"), e o template do componente provavelmente quebra ao tentar ler evento.titulo de undefined — props required não impedem a renderização, só avisam.
A2. Complete a linha que falta para que o evento favoritar só seja aceito se o payload for um número:
JavaScript
constemit=defineEmits({favoritar:____,})
Resultado esperado: (idEvento) => typeof idEvento === 'number' — uma função validadora que recebe o payload do evento e retorna true/false, no mesmo espírito do validator de defineProps.
A3. Em uma frase: por que useEventos() chamado duas vezes, em dois componentes diferentes, resulta em dois estados de carregamento independentes — enquanto uma store Pinia, chamada duas vezes, resulta num único estado compartilhado?
Resultado esperado: porque um composable é só uma função JavaScript comum — cada chamada executa o corpo de novo e cria refs novos; uma store Pinia é um singleton gerenciado pelo framework, então toda chamada de useXStore() devolve a mesma instância.
A4. Ache o erro nas linhas abaixo — a rota admin-eventos nunca renderiza nada quando o usuário acessa /admin diretamente (só a URL-base, sem sub-caminho):
Resultado esperado: falta uma rota-filha com path: '' (caminho vazio) para cobrir exatamente /admin sem sub-caminho nenhum — hoje /admin sozinho não bate com nenhuma rota-filha declarada, porque todas exigem um segmento extra (/admin/home, /admin/eventos).
A5. Preveja o comportamento: o scrollBehavior do router não foi declarado (a opção inteira foi omitida de createRouter). O usuário rola a página até o rodapé e clica em um <RouterLink> para outra rota.
Resultado esperado: a nova página aparece já rolada — o Vue Router preserva a posição de scroll atual por padrão quando scrollBehavior não está definido; é preciso declará-lo explicitamente (retornando { top: 0 }) para voltar ao topo a cada navegação.
B1.CartaoBase com slots nomeados. Crie o componente src/components/CartaoBase.vue com slots titulo, padrão e acoes (como na §2), e use-o para reescrever a tela SobreView.vue.
Resultado esperado: SobreView.vue usa <CartaoBase> com <template #titulo> e <template #acoes>, e a tela renderiza visualmente igual (ou melhor) do que antes.
Dica
<template #titulo>, conteúdo solto (sem <template>) cai no slot padrão, <template #acoes>.
B2. Composable useAlternanciaTema. Extraia a lógica de alternarTema/ehEscuro do CabecalhoApp.vue para um composable src/composables/useAlternanciaTema.js, e use-o também em uma nova tela de configurações.
Resultado esperado: CabecalhoApp.vue e a nova tela de configurações chamam useAlternanciaTema() e o clique em qualquer um dos dois lugares alterna o tema da aplicação inteira (porque useTheme() internamente já é global — o composable só organiza o acesso a ele).
Dica
O composable recebe useTheme() internamente e retorna { ehEscuro, alternarTema }.
B3. Guard de confirmação no formulário de novo evento. No AdminEventoFormView.vue, o onBeforeRouteLeave já existe, mas formularioAlterado nunca vira true ao digitar em campos que não passam por @update:model-value do form (ex.: se o navegador não disparar esse evento para todo campo). Ajuste para marcar formularioAlterado.value = true de forma confiável usando watch sobre os campos do formulário.
Resultado esperado: alterar qualquer campo do formulário e tentar sair da rota (clicar em "Cancelar" ou em um link do menu) dispara o window.confirm; salvar o formulário com sucesso não dispara mais o aviso ao sair em seguida.
B4.v-menu de ações rápidas no EventoCard. Adicione um v-menu com um botão de três pontinhos no EventoCard, com opções "Compartilhar" e "Favoritar", que emitem eventos compartilhar e favoritar para o componente pai.
Resultado esperado: clicar no botão de três pontinhos abre um menu com as duas opções; clicar em cada uma emite o evento correspondente, capturável com @compartilhar/@favoritar em quem usa o EventoCard.
Dica
Use o slot #activator="{ props }" do v-menu, como no exemplo da §5.
C1. Query string de paginação. Adicione um v-pagination na AdminEventosView.vue (fora do v-data-table, como exercício) e sincronize a página atual com ?pagina=N na URL, seguindo o padrão da §4. A URL precisa ser a fonte da verdade: recarregar a página em /admin/eventos?pagina=3 deve abrir já na página 3, e voltar/avançar no navegador entre páginas visitadas deve funcionar sem recarregar a tela.
Resultado esperado: /admin/eventos?pagina=2 abre direto na página 2 do v-pagination; clicar em outra página atualiza a URL sem recarregar; o botão "voltar" do navegador retorna à página anterior corretamente.
Dica
ref(Number(rota.query.pagina) || 1) inicializa o estado a partir da URL; um watch sobre esse ref chama router.push({ query: { ...rota.query, pagina } }) para refletir de volta; e um watch sobre rota.query.pagina (o caminho inverso) é o que faz o botão "voltar" do navegador também atualizar o v-pagination — sem ele, só a URL muda ao clicar em "voltar", não a tela.
Um colega criou DialogoConfirmacao.vue reaproveitando o desta aula, mas trocou um detalhe sem perceber. Ao clicar em "Excluir" no diálogo, nada acontece — o evento aparentemente nunca chega ao componente pai. Este é o trecho relevante:
Vue SFC
<!-- src/components/DialogoConfirmacao.vue — trecho com o bug plantado -->
<script setup>
const emit = defineEmits(['confirmar'])
function confirmar() {
emit('confirmado')
}
</script>
Abra o Vue DevTools (aba Components), selecione o DialogoConfirmacao e observe a lista de eventos emitidos ao clicar em "Excluir". O nome que aparece bate com o que o componente pai está escutando?
Critérios de pronto
Um comentário no topo do arquivo registra qual nome de evento o defineEmits declarava, qual nome estava realmente sendo emitido, e qual dos dois estava errado.
Clicar em "Excluir" agora dispara a função confirmarExclusao (ou equivalente) no componente pai, de forma confirmável no Vue DevTools ou com um console.log temporário.
Uma frase explica por que defineEmitsnão impede emitir um evento com nome diferente do declarado — e por que isso torna esse tipo de bug silencioso (sem erro, sem aviso).
Pistas
defineEmits(['confirmar']) só documenta e valida payloads — ele não bloqueia emit('outroNome'), mesmo que 'outroNome' não esteja na lista.
No Vue DevTools, a aba Components tem uma seção "Events" no painel de detalhes do componente selecionado — ela mostra o nome exato de cada evento emitido, em tempo real.
Compare, char por char, o nome usado em @confirmar="..." no componente pai com o nome usado em emit(...) no filho.
O v-menu de ações rápidas do Laboratório B4 funciona perfeitamente no mouse. Agora teste só com teclado: Tab até o botão de três pontinhos, Enter para abrir, Tab/setas para navegar pelas opções, Enter para escolher, Esc para fechar sem escolher nada. Em qual desses passos a experiência quebra?
Critérios de pronto
O botão de três pontinhos recebe foco visível com Tab e tem um aria-label descritivo (ex.: "Mais ações para o evento X" — o nome do evento entra dinamicamente no rótulo).
Abrir o menu com Enter (não só com clique) funciona, e o foco move para dentro do menu.
Esc fecha o menu e devolve o foco ao botão de três pontinhos — sem deixar o foco "perdido" em um elemento que sumiu da tela.
Um vídeo curto (ou GIF) de 10-15 segundos, gravado sem tocar no mouse, mostra o fluxo completo funcionando, anexado ao README do projeto autoral.
Pistas
v-btn aceita aria-label como qualquer atributo HTML — inclua o título do evento na string, usando template literal.
Verifique a documentação de acessibilidade do v-menu na versão do Vuetify instalada — componentes de menu geralmente já implementam boa parte da navegação por teclado, mas o aria-label do ativador é responsabilidade sua.
Para gravar sem mouse, o gravador de tela nativo do sistema operacional (ou a gravação de tela do próprio DevTools) já basta — não precisa de ferramenta especial.
O código abaixo passa o usuário logado por três componentes até chegar a quem realmente precisa dele — clássico prop drilling. PainelAdmin e CabecalhoSecao não usam usuarioLogado para nada além de repassar adiante:
Refatore essa cadeia usando provide/inject (§1), e depois responda: o que muda se PainelPerfil for renderizado em um lugar da árvore onde ninguém chamou provide('usuarioLogado', ...) acima dele?
Critérios de pronto
PainelAdmin e CabecalhoSecao não recebem mais usuarioLogado como prop — o dado só é declarado uma vez, no ancestral comum, com provide.
Um teste deliberado: renderize PainelPerfil em uma tela isolada, sem nenhum ancestral chamando provide. inject recebe um segundo argumento de valor padrão que evita a aplicação quebrar nesse caso — implemente e documente esse valor padrão.
Um parágrafo no README compara as duas abordagens: em quantos arquivos você precisou tocar para adicionar um novo dado "ambiental" (ex.: idioma da interface) em cada uma, e qual delas você escolheria para o seu projeto autoral, e por quê.
Pistas
provide('usuarioLogado', usuarioLogado) no componente ancestral mais alto que faz sentido (geralmente App.vue); inject('usuarioLogado', valorPadrao) em qualquer descendente, não importa a profundidade.
O segundo argumento de inject é o valor usado quando nenhum ancestral fez provide daquela chave — útil para não quebrar em testes isolados ou em Storybook.
Prop drilling não é "sempre errado" — em cadeias curtas (um ou dois níveis), a prop explícita ainda é mais fácil de rastrear do que provide/inject. O parágrafo do README deve refletir esse trade-off, não só repetir "provide/inject é melhor".
Extraia pelo menos um componente reutilizável de apresentação (equivalente ao EventoCard), com defineProps tipado e ao menos um evento emitido.
Crie um componente com slot nomeado (equivalente ao CartaoBase) e use-o em pelo menos duas telas diferentes.
Extraia a lógica de carregamento/filtro de dados para um composable use*.
Adicione uma área com rotas aninhadas (ex.: painel administrativo do seu domínio) com pelo menos duas rotas-filhas.
Crie um formulário de cadastro/edição com v-form e rules para pelo menos dois campos.
Critério de pronto: o formulário não deixa salvar com campos inválidos; a navegação entre rotas aninhadas funciona sem recarregar a página; pelo menos um componente usa slot nomeado com sucesso. Suba o commit no repositório.
Referências básicas do plano de curso: capítulos sobre reuso de componentes e roteamento avançado.
Na Aula 06 o UniEventos passa a consumir dados de uma API de verdade com Axios, organizados em uma camada de serviços — e o estado de eventos e inscrições migra para Pinia, substituindo os refs locais que temos usado até aqui.