Nível 3Unidade 2 · Vue.js avançado: Vuetify, Axios, Router e Pinia3 aulas de 50 min + 1 h EAD

Aula 05 — Componentes, Vue Router e Vuetify avançado

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

📋 Pré-requisitos desta aula

Na Aula 04 você transformou o UniEventos em uma SPA navegável. Hoje ele fica modular: em vez de views monolíticas com tudo dentro, cada pedaço de interface vira um componente com contrato próprio — e a área administrativa ganha rotas aninhadas e formulários validados.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Componentização a sério: props, emits, v-model customizado, provide/inject, slots
2 50 min Composables, Vue Router avançado (rotas aninhadas, guards, query strings), formulários e v-data-table
3 50 min Mão na massa: refatoração do UniEventos em componentes + área administrativa

1. Componentização a sério

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).

Granularidade: quando quebrar em componente

Não existe regra rígida, mas alguns sinais indicam que é hora de extrair um componente:

⚠️ 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.

Componente burro vs. componente inteligente

Uma distinção útil (não uma regra do Vue, mas um padrão de arquitetura comum em SPAs):

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.

defineProps com contrato completo

Você já usou defineProps de forma simples nas aulas anteriores. Um contrato robusto de props declara tipo, obrigatoriedade, valor padrão e validação:

Vue SFC
<script setup>
const props = defineProps({
  evento: {
    type: Object,
    required: true,
  },
  destaque: {
    type: Boolean,
    default: false,
  },
  tamanhoImagem: {
    type: Number,
    default: 160,
    validator: (valor) => valor >= 100 && valor <= 400,
  },
})
</script>

💡 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 e comunicação filho → pai

Vue SFC
<!-- src/components/EventoCard.vue (trecho) -->
<script setup>
const props = defineProps({
  evento: { type: Object, required: true },
})

const emit = defineEmits({
  inscrever: (idEvento) => typeof idEvento === 'number',
  favoritar: null, // sem validação
})

function aoClicarInscrever() {
  emit('inscrever', props.evento.id)
}
</script>

<template>
  <v-card>
    <!-- ... -->
    <v-btn color="primary" @click="aoClicarInscrever">Inscrever-se</v-btn>
  </v-card>
</template>

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:

Vue SFC
<EventoCard :evento="evento" @inscrever="tratarInscricao" />

v-model em componente customizado

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.

Forma moderna — defineModel() (Vue 3.4+):

Vue SFC
<!-- src/components/CampoBusca.vue -->
<script setup>
const modelo = defineModel({ type: String, default: '' })
</script>

<template>
  <v-text-field
    v-model="modelo"
    label="Buscar"
    prepend-inner-icon="mdi-magnify"
    variant="outlined"
    clearable
  />
</template>
Vue SFC
<CampoBusca v-model="termoBusca" />

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.

Forma clássica — modelValue / update:modelValue:

Vue SFC
<!-- src/components/CampoBusca.vue (equivalente, forma clássica) -->
<script setup>
const props = defineProps({
  modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <v-text-field
    :model-value="modelValue"
    label="Buscar"
    prepend-inner-icon="mdi-magnify"
    variant="outlined"
    clearable
    @update:model-value="emit('update:modelValue', $event)"
  />
</template>

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 disciplina 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.

provide/inject para dados profundos

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:

Vue SFC
<!-- src/App.vue (trecho) -->
<script setup>
import { provide, ref } from 'vue'

const usuarioLogado = ref({ nome: 'Convidado' })
provide('usuarioLogado', usuarioLogado)
</script>
Vue SFC
<!-- 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).

Atributos fallthrough e inheritAttrs

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:

Vue SFC
<EventoCard :evento="evento" class="destaque" data-testid="card-evento" />

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:

Vue SFC
<script setup>
defineOptions({ inheritAttrs: false })
</script>

<template>
  <div class="wrapper">
    <v-card v-bind="$attrs">
      <!-- conteúdo -->
    </v-card>
  </div>
</template>

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.

2. Slots: componentes de layout reutilizáveis

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.

Slot padrão

Vue SFC
<!-- src/components/CartaoBase.vue -->
<template>
  <v-card class="pa-4">
    <slot />
  </v-card>
</template>
Vue SFC
<CartaoBase>
  <h3>Qualquer conteúdo aqui</h3>
  <p>O CartaoBase não sabe nem precisa saber o que vai dentro.</p>
</CartaoBase>

Slots nomeados

Um componente pode ter vários "buracos" com papéis diferentes:

Vue SFC
<!-- src/components/CartaoBase.vue -->
<template>
  <v-card>
    <v-card-title>
      <slot name="titulo">Sem título</slot>
    </v-card-title>
    <v-card-text>
      <slot />
    </v-card-text>
    <v-card-actions>
      <slot name="acoes" />
    </v-card-actions>
  </v-card>
</template>
Vue SFC
<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>.

Slots com escopo (scoped slots)

À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:

Vue SFC
<!-- src/components/EventoLista.vue (trecho) -->
<template>
  <div v-for="evento in eventos" :key="evento.id">
    <slot name="item" :evento="evento" :formatarData="formatarData" />
  </div>
</template>
Vue SFC
<EventoLista :eventos="listaEventos">
  <template #item="{ evento, formatarData }">
    <v-card>
      <v-card-title>{{ evento.titulo }}</v-card-title>
      <v-card-subtitle>{{ formatarData(evento.dataHora) }}</v-card-subtitle>
    </v-card>
  </template>
</EventoLista>

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.

3. Composables: extraindo lógica reutilizável

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*.

JavaScript
// src/composables/useEventos.js
import { ref, computed, onMounted } from 'vue'
import { eventos as eventosBase } from '../data/eventos'

export function useEventos() {
  const carregando = ref(true)
  const eventos = ref([])
  const categoriaFiltro = ref('Todas')
  const busca = ref('')

  onMounted(() => {
    setTimeout(() => {
      eventos.value = eventosBase
      carregando.value = false
    }, 300)
  })

  const eventosFiltrados = computed(() => {
    return eventos.value.filter((evento) => {
      const bateCategoria =
        categoriaFiltro.value === 'Todas' ||
        evento.categoria === categoriaFiltro.value.toLowerCase()
      const bateBusca = evento.titulo
        .toLowerCase()
        .includes(busca.value.toLowerCase())
      return bateCategoria && bateBusca
    })
  })

  return {
    carregando,
    eventos,
    categoriaFiltro,
    busca,
    eventosFiltrados,
  }
}

Qualquer componente que precise dessa lógica simplesmente chama a função:

Vue SFC
<script setup>
import { useEventos } from '../composables/useEventos'

const { carregando, categoriaFiltro, busca, eventosFiltrados } = useEventos()
</script>

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).

Por que composables substituem mixins

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:

JavaScript
const { eventosFiltrados: eventosDaHome } = useEventos()

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.

4. Vue Router avançado

Rotas aninhadas (children)

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:

JavaScript
// src/router/index.js (trecho)
{
  path: '/admin',
  component: () => import('../views/admin/AdminLayoutView.vue'),
  children: [
    { path: '', name: 'admin-home', component: () => import('../views/admin/AdminHomeView.vue') },
    { path: 'eventos', name: 'admin-eventos', component: () => import('../views/admin/AdminEventosView.vue') },
    { path: 'eventos/novo', name: 'admin-evento-novo', component: () => import('../views/admin/AdminEventoFormView.vue') },
    { path: 'eventos/:id/editar', name: 'admin-evento-editar', component: () => import('../views/admin/AdminEventoFormView.vue') },
  ],
}

O componente pai da rota (AdminLayoutView.vue) precisa ter seu próprio <RouterView /> — é onde as rotas-filhas serão renderizadas:

Vue SFC
<!-- src/views/admin/AdminLayoutView.vue -->
<template>
  <v-container>
    <v-tabs>
      <v-tab :to="{ name: 'admin-home' }">Painel</v-tab>
      <v-tab :to="{ name: 'admin-eventos' }">Eventos</v-tab>
    </v-tabs>
    <RouterView />
  </v-container>
</template>

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.

meta em rotas

Cada rota pode carregar metadados arbitrários, usados por guards ou pela própria interface (ex.: título da página, exigência de autenticação):

JavaScript
{
  path: 'eventos/novo',
  name: 'admin-evento-novo',
  component: () => import('../views/admin/AdminEventoFormView.vue'),
  meta: { requerAutenticacao: true, titulo: 'Novo evento' },
}

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'

  const autenticado = false // substituiremos por estado real com Pinia na Aula 06
  if (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:

JavaScript
{
  path: 'eventos/:id/editar',
  name: 'admin-evento-editar',
  component: () => import('../views/admin/AdminEventoFormView.vue'),
  beforeEnter: (to) => {
    if (Number.isNaN(Number(to.params.id))) {
      return { name: 'nao-encontrado' }
    }
  },
}

onBeforeRouteLeave — guard dentro do componente, útil para confirmar saída de um formulário com alterações não salvas:

Vue SFC
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const formularioAlterado = ref(true)

onBeforeRouteLeave(() => {
  if (formularioAlterado.value) {
    const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
    if (!confirmar) return false
  }
})
</script>

Query strings sincronizadas com filtros

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.

Scroll behavior

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)
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition // navegação por botão voltar/avançar
    return { top: 0 }
  },
  routes: [ /* ... */ ],
})

Layouts diferentes por rota

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.

5. Vuetify aprofundado

v-form com validação por regras

Vue SFC
<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
  // ... enviar dados
}
</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>

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

Vue SFC
<script setup>
import { ref } from 'vue'
import { eventos } from '../data/eventos'

const cabecalhos = [
  { title: 'Título', key: 'titulo' },
  { title: 'Categoria', key: 'categoria' },
  { title: 'Data', key: 'dataHora' },
  { title: 'Vagas', key: 'vagas' },
  { title: 'Ações', key: 'acoes', sortable: false },
]

const busca = ref('')
</script>

<template>
  <v-text-field v-model="busca" label="Buscar" prepend-inner-icon="mdi-magnify" class="mb-4" />

  <v-data-table
    :headers="cabecalhos"
    :items="eventos"
    :search="busca"
    items-per-page="5"
  >
    <template #item.acoes="{ item }">
      <v-btn icon="mdi-pencil" size="small" variant="text" />
      <v-btn icon="mdi-delete" size="small" variant="text" color="error" />
    </template>
  </v-data-table>
</template>

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>
Vue SFC
<!-- abas -->
<v-tabs v-model="abaAtiva">
  <v-tab value="proximos">Próximos</v-tab>
  <v-tab value="encerrados">Encerrados</v-tab>
</v-tabs>
<v-window v-model="abaAtiva">
  <v-window-item value="proximos">...</v-window-item>
  <v-window-item value="encerrados">...</v-window-item>
</v-window>
Vue SFC
<!-- menu de contexto -->
<v-menu>
  <template #activator="{ props: propsAtivador }">
    <v-btn icon="mdi-dots-vertical" variant="text" v-bind="propsAtivador" />
  </template>
  <v-list>
    <v-list-item title="Editar" prepend-icon="mdi-pencil" />
    <v-list-item title="Excluir" prepend-icon="mdi-delete" />
  </v-list>
</v-menu>

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.

Vue SFC
<!-- esqueleto de carregamento -->
<v-skeleton-loader v-if="carregando" type="card" />
<EventoCard v-else :evento="evento" />

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.

Vue SFC
<!-- paginação manual (fora do v-data-table) -->
<v-pagination v-model="paginaAtual" :length="totalPaginas" />

🧩 Padrão de projeto em uso

🧩 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

Passo 1 — criar EventoCard.vue

Vue SFC
<!-- src/components/EventoCard.vue -->
<script setup>
const props = defineProps({
  evento: { type: Object, required: true },
})

const emit = defineEmits({
  inscrever: (idEvento) => typeof idEvento === 'number',
})

function formatarData(dataIso) {
  return new Date(dataIso).toLocaleDateString('pt-BR', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
  })
}
</script>

<template>
  <v-card :to="{ name: 'evento-detalhe', params: { id: evento.id } }">
    <v-img :src="evento.imagemUrl" height="160" cover />
    <v-card-title>{{ evento.titulo }}</v-card-title>
    <v-card-subtitle>
      {{ formatarData(evento.dataHora) }} · {{ evento.local }}
    </v-card-subtitle>
    <v-card-actions>
      <v-chip color="secondary" size="small">{{ evento.categoria }}</v-chip>
      <v-spacer />
      <v-chip :color="evento.vagas > 0 ? 'success' : 'error'" size="small">
        {{ evento.vagas > 0 ? `${evento.vagas} vagas` : 'Esgotado' }}
      </v-chip>
    </v-card-actions>
  </v-card>
</template>

Passo 2 — criar EventoLista.vue

Vue SFC
<!-- src/components/EventoLista.vue -->
<script setup>
defineProps({
  eventos: { type: Array, required: true },
})
</script>

<template>
  <v-row>
    <v-col
      v-for="evento in eventos"
      :key="evento.id"
      cols="12"
      sm="6"
      md="4"
    >
      <EventoCard :evento="evento" />
    </v-col>
  </v-row>
</template>

Graças ao autoImport do Vuetify e ao registro automático de componentes de mesmo diretório do Vite/Vue no scaffold, EventoCard é usado aqui sem import explícito — mas em projetos com muitos componentes é comum importar explicitamente por clareza. Vamos manter import explícito nas próximas telas para deixar as dependências óbvias.

Vue SFC
<!-- src/components/EventoLista.vue — versão com import explícito -->
<script setup>
import EventoCard from './EventoCard.vue'

defineProps({
  eventos: { type: Array, required: true },
})
</script>

<template>
  <v-row>
    <v-col
      v-for="evento in eventos"
      :key="evento.id"
      cols="12"
      sm="6"
      md="4"
    >
      <EventoCard :evento="evento" />
    </v-col>
  </v-row>
</template>

Passo 3 — criar FiltroEventos.vue com v-model duplo

Vue SFC
<!-- src/components/FiltroEventos.vue -->
<script setup>
const busca = defineModel('busca', { type: String, default: '' })
const categoria = defineModel('categoria', { type: String, default: 'Todas' })

const categorias = ['Todas', 'Palestra', 'Minicurso', 'Workshop']
</script>

<template>
  <v-row class="mb-2">
    <v-col cols="12" md="6">
      <v-text-field
        v-model="busca"
        label="Buscar evento"
        prepend-inner-icon="mdi-magnify"
        variant="outlined"
        density="compact"
        clearable
      />
    </v-col>
    <v-col cols="12" md="6">
      <v-select
        v-model="categoria"
        :items="categorias"
        label="Categoria"
        variant="outlined"
        density="compact"
      />
    </v-col>
  </v-row>
</template>

defineModel('busca', ...) e defineModel('categoria', ...) são a forma de defineModel() para múltiplos v-models no mesmo componente — cada nome vira um par prop/evento independente:

Vue SFC
<FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />

Passo 4 — criar CabecalhoApp.vue e RodapeApp.vue

Vue SFC
<!-- src/components/CabecalhoApp.vue -->
<script setup>
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { RouterLink } from 'vue-router'

defineProps({
  totalEventos: { type: Number, default: 0 },
})

const emit = defineEmits(['abrir-menu'])

const tema = useTheme()
const ehEscuro = computed(() => tema.global.name.value === 'dark')

function alternarTema() {
  tema.global.name.value = ehEscuro.value ? 'light' : 'dark'
}
</script>

<template>
  <v-app-bar color="primary">
    <v-app-bar-nav-icon @click="emit('abrir-menu')" />
    <v-app-bar-title>
      <RouterLink to="/" class="text-white text-decoration-none">UniEventos</RouterLink>
    </v-app-bar-title>
    <v-chip color="secondary" size="small" class="mr-2">{{ totalEventos }} eventos</v-chip>
    <v-spacer />
    <v-btn
      :icon="ehEscuro ? 'mdi-weather-sunny' : 'mdi-weather-night'"
      variant="text"
      @click="alternarTema"
    />
  </v-app-bar>
</template>
Vue SFC
<!-- src/components/RodapeApp.vue -->
<script setup>
</script>

<template>
  <v-footer color="primary" class="d-flex justify-center pa-4">
    <span class="text-white">UNEMAT · FACET · FACET-SNP-310 · 2026.2</span>
  </v-footer>
</template>

Passo 5 — criar DialogoConfirmacao.vue

Vue SFC
<!-- src/components/DialogoConfirmacao.vue -->
<script setup>
const aberto = defineModel({ type: Boolean, default: false })

defineProps({
  titulo: { type: String, default: 'Confirmar ação' },
  mensagem: { type: String, default: 'Esta ação não pode ser desfeita.' },
})

const emit = defineEmits(['confirmar'])

function confirmar() {
  emit('confirmar')
  aberto.value = false
}
</script>

<template>
  <v-dialog v-model="aberto" max-width="400" persistent>
    <v-card :title="titulo" :text="mensagem">
      <v-card-actions>
        <v-spacer />
        <v-btn variant="text" @click="aberto = false">Cancelar</v-btn>
        <v-btn color="error" variant="flat" @click="confirmar">Confirmar</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

Passo 6 — atualizar App.vue para usar CabecalhoApp e RodapeApp

Vue SFC
<!-- src/App.vue -->
<script setup>
import { ref, computed } from 'vue'
import { RouterView } from 'vue-router'
import CabecalhoApp from './components/CabecalhoApp.vue'
import RodapeApp from './components/RodapeApp.vue'
import { eventos } from './data/eventos'

const drawerAberto = ref(false)
const totalEventos = computed(() => eventos.length)

const linksMenu = [
  { titulo: 'Início', rota: 'home', icone: 'mdi-home' },
  { titulo: 'Sobre', rota: 'sobre', icone: 'mdi-information' },
  { titulo: 'Administração', rota: 'admin-home', icone: 'mdi-cog' },
]
</script>

<template>
  <v-app>
    <CabecalhoApp :total-eventos="totalEventos" @abrir-menu="drawerAberto = !drawerAberto" />

    <v-navigation-drawer v-model="drawerAberto" temporary>
      <v-list>
        <v-list-item
          v-for="link in linksMenu"
          :key="link.rota"
          :to="{ name: link.rota }"
          :title="link.titulo"
          :prepend-icon="link.icone"
        />
      </v-list>
    </v-navigation-drawer>

    <v-main>
      <RouterView />
    </v-main>

    <RodapeApp />
  </v-app>
</template>

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.

Passo 8 — criar rotas administrativas aninhadas

JavaScript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition
    return { top: 0 }
  },
  routes: [
    { path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
    { path: '/eventos/:id', name: 'evento-detalhe', component: () => import('../views/EventoDetalheView.vue') },
    { path: '/sobre', name: 'sobre', component: () => import('../views/SobreView.vue'), meta: { titulo: 'Sobre' } },
    {
      path: '/admin',
      component: () => import('../views/admin/AdminLayoutView.vue'),
      children: [
        { path: '', name: 'admin-home', component: () => import('../views/admin/AdminHomeView.vue'), meta: { titulo: 'Painel administrativo' } },
        { path: 'eventos', name: 'admin-eventos', component: () => import('../views/admin/AdminEventosView.vue'), meta: { titulo: 'Gerenciar eventos' } },
        { path: 'eventos/novo', name: 'admin-evento-novo', component: () => import('../views/admin/AdminEventoFormView.vue'), meta: { titulo: 'Novo evento' } },
        {
          path: 'eventos/:id/editar',
          name: 'admin-evento-editar',
          component: () => import('../views/admin/AdminEventoFormView.vue'),
          meta: { titulo: 'Editar evento' },
          beforeEnter: (to) => {
            if (Number.isNaN(Number(to.params.id))) {
              return { name: 'nao-encontrado' }
            }
          },
        },
      ],
    },
    { path: '/:pathMatch(.*)*', name: 'nao-encontrado', component: () => import('../views/NaoEncontradoView.vue') },
  ],
})

router.beforeEach((to) => {
  document.title = to.meta.titulo ? `${to.meta.titulo} · UniEventos` : 'UniEventos'
})

export default router

Passo 9 — criar AdminLayoutView.vue

Vue SFC
<!-- src/views/admin/AdminLayoutView.vue -->
<script setup>
import { ref } from 'vue'
import { RouterView } from 'vue-router'

const abaAtiva = ref('admin-eventos')
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">Administração</h1>
    <v-tabs v-model="abaAtiva" class="mb-4">
      <v-tab value="admin-home" :to="{ name: 'admin-home' }">Painel</v-tab>
      <v-tab value="admin-eventos" :to="{ name: 'admin-eventos' }">Eventos</v-tab>
    </v-tabs>
    <RouterView />
  </v-container>
</template>

Passo 10 — criar AdminHomeView.vue e AdminEventosView.vue

Vue SFC
<!-- src/views/admin/AdminHomeView.vue -->
<script setup>
import { eventos } from '../../data/eventos'
</script>

<template>
  <v-row>
    <v-col cols="12" sm="4">
      <v-card class="pa-4 text-center">
        <div class="text-h3 text-primary">{{ eventos.length }}</div>
        <div>eventos cadastrados</div>
      </v-card>
    </v-col>
  </v-row>
</template>
Vue SFC
<!-- src/views/admin/AdminEventosView.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { eventos } from '../../data/eventos'
import DialogoConfirmacao from '../../components/DialogoConfirmacao.vue'

const router = useRouter()
const dialogoAberto = ref(false)
const eventoParaExcluir = ref(null)

const cabecalhos = [
  { title: 'Título', key: 'titulo' },
  { title: 'Categoria', key: 'categoria' },
  { title: 'Vagas', key: 'vagas' },
  { title: 'Ações', key: 'acoes', sortable: false },
]

const busca = ref('')

function pedirExclusao(evento) {
  eventoParaExcluir.value = evento
  dialogoAberto.value = true
}

function confirmarExclusao() {
  const indice = eventos.findIndex((e) => e.id === eventoParaExcluir.value.id)
  if (indice !== -1) eventos.splice(indice, 1)
  eventoParaExcluir.value = null
}
</script>

<template>
  <div>
    <div class="d-flex justify-space-between align-center mb-4">
      <v-text-field
        v-model="busca"
        label="Buscar"
        prepend-inner-icon="mdi-magnify"
        density="compact"
        style="max-width: 300px"
      />
      <v-btn color="primary" prepend-icon="mdi-plus" :to="{ name: 'admin-evento-novo' }">
        Novo evento
      </v-btn>
    </div>

    <v-data-table :headers="cabecalhos" :items="eventos" :search="busca" items-per-page="5">
      <template #item.acoes="{ item }">
        <v-btn
          icon="mdi-pencil"
          size="small"
          variant="text"
          :to="{ name: 'admin-evento-editar', params: { id: item.id } }"
        />
        <v-btn
          icon="mdi-delete"
          size="small"
          variant="text"
          color="error"
          @click="pedirExclusao(item)"
        />
      </template>
    </v-data-table>

    <DialogoConfirmacao
      v-model="dialogoAberto"
      titulo="Excluir evento"
      :mensagem="`Excluir '${eventoParaExcluir?.titulo}'? Esta ação não pode ser desfeita.`"
      @confirmar="confirmarExclusao"
    />
  </div>
</template>

Passo 11 — criar AdminEventoFormView.vue com validação

Vue SFC
<!-- src/views/admin/AdminEventoFormView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { eventos } from '../../data/eventos'

const rota = useRoute()
const router = useRouter()

const modoEdicao = computed(() => rota.name === 'admin-evento-editar')
const formRef = ref(null)
const formularioAlterado = ref(false)

const titulo = ref('')
const descricao = ref('')
const categoria = ref('palestra')
const local = ref('')
const vagas = ref(null)

const categorias = ['palestra', 'minicurso', 'workshop']

const regrasTitulo = [
  (v) => !!v || 'O título é obrigatório',
  (v) => (v && v.length >= 5) || 'Mínimo de 5 caracteres',
]
const regrasLocal = [(v) => !!v || 'O local é obrigatório']
const regrasVagas = [
  (v) => !!v || 'Informe o número de vagas',
  (v) => v > 0 || 'Deve ser maior que zero',
]

onMounted(() => {
  if (modoEdicao.value) {
    const evento = eventos.find((e) => e.id === Number(rota.params.id))
    if (evento) {
      titulo.value = evento.titulo
      descricao.value = evento.descricao
      categoria.value = evento.categoria
      local.value = evento.local
      vagas.value = evento.vagas
    }
  }
})

onBeforeRouteLeave(() => {
  if (formularioAlterado.value) {
    const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
    if (!confirmar) return false
  }
})

async function salvar() {
  const { valid } = await formRef.value.validate()
  if (!valid) return

  if (modoEdicao.value) {
    const evento = eventos.find((e) => e.id === Number(rota.params.id))
    Object.assign(evento, {
      titulo: titulo.value,
      descricao: descricao.value,
      categoria: categoria.value,
      local: local.value,
      vagas: vagas.value,
    })
  } else {
    const novoId = Math.max(...eventos.map((e) => e.id)) + 1
    eventos.push({
      id: novoId,
      titulo: titulo.value,
      descricao: descricao.value,
      categoria: categoria.value,
      local: local.value,
      vagas: vagas.value,
      dataHora: new Date().toISOString(),
      imagemUrl: `https://picsum.photos/seed/evento${novoId}/600/300`,
    })
  }

  formularioAlterado.value = false
  router.push({ name: 'admin-eventos' })
}
</script>

<template>
  <v-card class="pa-4">
    <v-card-title>{{ modoEdicao ? 'Editar evento' : 'Novo evento' }}</v-card-title>
    <v-card-text>
      <v-form ref="formRef" @submit.prevent="salvar" @update:model-value="formularioAlterado = true">
        <v-text-field v-model="titulo" label="Título" :rules="regrasTitulo" class="mb-2" />
        <v-textarea v-model="descricao" label="Descrição" rows="3" class="mb-2" />
        <v-select v-model="categoria" :items="categorias" label="Categoria" class="mb-2" />
        <v-text-field v-model="local" label="Local" :rules="regrasLocal" class="mb-2" />
        <v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" class="mb-4" />
        <v-btn type="submit" color="primary" variant="flat">Salvar</v-btn>
        <v-btn variant="text" class="ml-2" :to="{ name: 'admin-eventos' }">Cancelar</v-btn>
      </v-form>
    </v-card-text>
  </v-card>
</template>

🧪 Laboratório

1. 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.

Dica

<template #titulo>, conteúdo solto (sem <template>) cai no slot padrão, <template #acoes>.

2. 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.

Dica

O composable recebe useTheme() internamente e retorna { ehEscuro, alternarTema }.

3. 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.

Dica

watch([titulo, descricao, categoria, local, vagas], () => { formularioAlterado.value = true }).

4. 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.

Dica

ref(Number(rota.query.pagina) || 1) mais um watch que chama router.push({ query: { ...rota.query, pagina } }).

5. 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.

Dica

Use o slot #activator="{ props }" do v-menu, como no exemplo da §5.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
v-model em componente customizado não atualiza nada Componente não implementa defineModel() nem o par modelValue/update:modelValue Adicione defineModel() (ou a forma clássica) no componente filho
Prop chega como undefined mesmo passando valor no pai Nome da prop em kebab-case no template não bate com o nome em camelCase no defineProps Vue converte automaticamente total-eventostotalEventos — confira grafia exata dos dois lados
formRef.value.validate() lança erro "Cannot read properties of null" O ref="formRef" não está associado a um <v-form> ainda montado (chamado antes do onMounted) Garanta que a chamada acontece após o componente montar, tipicamente dentro de um handler de clique
No v-slot="{ isValid }" do v-form, isValid.value é undefined Código copiado do Vuetify 3 — variáveis de slot não são mais refs no Vuetify 4 Use isValid diretamente, sem .value
RouterView da rota aninhada nunca renderiza Falta um <RouterView /> dentro do componente de layout (AdminLayoutView.vue) Toda rota com children precisa de um RouterView próprio no componente pai da rota
onBeforeRouteLeave não é chamado Guard declarado fora de um componente renderizado pela rota (ex.: em um componente filho que não é o componente-alvo da rota) onBeforeRouteLeave só funciona dentro do componente que a rota renderiza diretamente
Slot com escopo não recebe os dados esperados Faltou vincular os dados no <slot> do componente filho (:evento="evento") Toda variável que o slot precisa expor deve ser passada como atributo do <slot>

🏠 Atividade assíncrona (1 h)

No seu projeto autoral:

  1. Extraia pelo menos um componente reutilizável de apresentação (equivalente ao EventoCard), com defineProps tipado e ao menos um evento emitido.
  2. Crie um componente com slot nomeado (equivalente ao CartaoBase) e use-o em pelo menos duas telas diferentes.
  3. Extraia a lógica de carregamento/filtro de dados para um composable use*.
  4. Adicione uma área com rotas aninhadas (ex.: painel administrativo do seu domínio) com pelo menos duas rotas-filhas.
  5. 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.

✅ Checkpoint do projeto autoral

📚 Para aprofundar

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.

WebLab — Laboratório de Desenvolvimento Web · UNEMAT — Universidade do Estado de Mato Grosso · Campus Sinop · FACET
Prof. Ivan Luiz Pedroso Pires · Material didático de uso educacional; livre para consulta, estudo e reuso com atribuição.
Início · Banco de Desafios · Links úteis · Fontes no GitHub