Formulário de Filtragem Expansível com CSS Grid e Vue.js

Em aplicações web que lidam com grandes volumes de dados, é comum a necessidade de formulários de busca e filtragem que permitem múltiplos critérios. Para gerenciar a complexidade e a usabilidade, especialmente quando há muitos campos, a funcionalidade de expandir/recolher o formulário de filtros é extremamente útil. Este artigo demonstra como implementar um componente de formulário de busca avançada com essa caapcidade, utilizando CSS Grid para o layout e o Vue.js (com Element Plus) para a lógica interativa e a injeção dinâmica de estilos.

Layout com CSS Grid

A CSS Grid oferece uma maneira poderosa e flexível de criar layouts bidimensionais. Para um formulário de filtro, podemos definir um número fixo de colunas e posicionar os itens de formulário automaticamente. O desafio reside em ocultar dinamicamente um subconjunto desses campos quando o formulário está "recolhido", enquanto sempre exibimos os primeiros e o último (que geralmente contém os botões de ação).

A estrutura base do formulário de busca pode ser definida com CSS Grid da seguinte forma:


.search-filter-grid {
 display: grid;
 grid-template-columns: repeat(var(--filter-columns, 4), 1fr); /* Número de colunas configurável */
 gap: 10px; /* Espaçamento entre os itens */

 /* Estilos para os itens de formulário (Element Plus) */
 :global(.el-form-item) {
   margin-bottom: 0; /* Remove margem inferior padrão */

   /* Garante que o último item (botões) ocupe todas as colunas restantes */
   &:last-child {
     grid-column-end: span var(--filter-columns, 4);
     justify-self: end; /* Alinha os botões à direita */
   }
 }
}

No entanto, a parte mais complicada é ocultar os campos intermediários quando o formulário está recolhido. A seletor :nth-child(n + X) é ideal para isso, mas o valor X precisa ser dinâmico (baseado no número de colunas). Como o v-bind() do Vue no <style module> não pode ser usado diretamente dentro de seletores complexos como nth-child para valores arbitrários, precisamos injetar este estilo dinamicamente.

Componente de Filtro Expansível (FilterPanel.vue)

Vamos criar um componente Vue 3 para encapsular essa funcionalidade.


<script setup lang="ts">
import { computed, ref, useCssModule, watch, onUnmounted } from 'vue';
import type { FormInstance, FormProps } from 'element-plus';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';

// Definição das propriedades do componente
interface FilterPanelProps extends Omit<FormProps, 'inline'> {
 gridColumns?: number; // Número de colunas para o layout Grid
}

const props = withDefaults(defineProps<FilterPanelProps>(), {
 gridColumns: 4,
});

// Emits para notificar o componente pai sobre ações
const emits = defineEmits<{
 (e: 'visibilityChange', visible: boolean): void;
 (e: 'resetFilters'): void;
 (e: 'applyFilters'): void;
}>();

const style = useCssModule(); // Obtém os estilos CSS do módulo

const formRef = ref<FormInstance | null>(null);
const isExpanded = ref(false); // Estado de expansão do formulário

// Propriedades do formulário herdadas
const inheritedFormProps = computed(() => {
 const { gridColumns, ...restProps } = props;
 return restProps;
});

// Manipulador para alternar a visibilidade do formulário
const toggleVisibility = () => {
 isExpanded.value = !isExpanded.value;
 emits('visibilityChange', isExpanded.value);
};

// Manipulador para redefinir os filtros
const handleReset = () => {
 emits('resetFilters');
 formRef.value?.resetFields(); // Reseta os campos do formulário Element Plus
};

// Manipulador para aplicar os filtros
const handleApply = () => {
 emits('applyFilters');
 formRef.value?.validate((isValid) => {
   if (isValid) {
     console.log('Filtros aplicados!');
   } else {
     console.log('Formulário inválido!');
   }
 });
};

// Referência para o elemento <style> injetado
const dynamicStyleElement = ref<HTMLStyleElement | null>(null);

// Função para injetar dinamicamente as regras CSS para ocultar/mostrar campos
const injectDynamicVisibilityStyles = (columns: number) => {
 const { filterFormContainer } = style; // Obtém o nome da classe CSS do módulo
 
 // A regra CSS oculta todos os itens de formulário a partir da 'columns'-ésima posição
 // exceto o último item (que contém os botões de ação).
 const cssRule = `
   .${filterFormContainer}:not(.expanded) .el-form-item:nth-child(n + ${columns + 1}):not(:last-child) {
     display: none;
   }
 `;
 const cssTextNode = document.createTextNode(cssRule);

 if (dynamicStyleElement.value) {
   // Se o elemento <style> já existe, atualiza seu conteúdo
   if (dynamicStyleElement.value.firstChild) {
     dynamicStyleElement.value.removeChild(dynamicStyleElement.value.firstChild);
   }
   dynamicStyleElement.value.appendChild(cssTextNode);
 } else {
   // Caso contrário, cria e adiciona um novo elemento <style> ao cabeçalho do documento
   const styleTag = document.createElement('style');
   styleTag.appendChild(cssTextNode);
   dynamicStyleElement.value = styleTag;
   document.head.appendChild(styleTag);
 }
};

// Observa mudanças na propriedade gridColumns para atualizar os estilos dinâmicos
watch(
 () => props.gridColumns,
 (newColumns) => {
   injectDynamicVisibilityStyles(newColumns);
 },
 { immediate: true } // Executa imediatamente na montagem do componente
);

// Limpa o elemento <style> injetado quando o componente é desmontado
onUnmounted(() => {
 if (dynamicStyleElement.value && document.head.contains(dynamicStyleElement.value)) {
   document.head.removeChild(dynamicStyleElement.value);
 }
});

// Expõe o ref do formulário para acesso externo
defineExpose({
 form: formRef,
});
</script>

<template>
 <el-form
   ref="formRef"
   v-bind="inheritedFormProps"
   @submit.prevent="handleApply"
   @reset.prevent="handleReset"
   :class="[style.filterFormContainer, { expanded: isExpanded }]"
   :style="{ '--filter-columns': props.gridColumns }"
 >
   <slot></slot>
   <el-form-item>
     <el-button @click="toggleVisibility" :icon="isExpanded ? ArrowUp : ArrowDown">
       {{ isExpanded ? 'Recolher' : 'Expandir' }}
     </el-button>
     <el-button type="primary" native-type="submit">Pesquisar</el-button>
     <el-button native-type="reset">Limpar</el-button>
   </el-form-item>
 </el-form>
</template>

<style module lang="scss">
.filterFormContainer {
 display: grid;
 grid-template-columns: repeat(v-bind(props.gridColumns), 1fr);
 gap: 10px;

 /* Estilos específicos para os itens do Element Plus dentro deste container */
 :global(.el-form-item) {
   margin-bottom: 0;

   /* O último item (botões) ocupa as colunas restantes */
   &:last-child {
     grid-column: v-bind('props.gridColumns + 1'); /* Inicia na próxima coluna após a última definida */
     grid-column-end: span v-bind(props.gridColumns); /* Ocupa 'gridColumns' de largura */
     justify-self: end; /* Alinha os botões à direita */
   }
 }
}
</style>

Como Utilizar o Componente

Para usar o FilterPanel, basta envolvê-lo em seus campos el-form-item e passar um objeto de modelo de dados:


<script setup lang="ts">
import { reactive, ref } from 'vue';
import FilterPanel from './FilterPanel.vue'; // Ajuste o caminho conforme necessário

const filterData = reactive({
 name: '',
 region: '',
 category: '',
 status: '',
 dateRange: '',
 priority: '',
 owner: '',
 creationDate: '',
 lastUpdate: '',
 tags: '',
});

// Opcional: manipular eventos do componente
const handleVisibilityChange = (isVisible: boolean) => {
 console.log(`Formulário agora está ${isVisible ? 'expandido' : 'recolhido'}`);
};

const handleResetFilters = () => {
 console.log('Filtros foram resetados.');
 Object.keys(filterData).forEach(key => (filterData[key] = '')); // Limpa todos os campos
};

const handleApplyFilters = () => {
 console.log('Filtros aplicados:', filterData);
 // Aqui você faria a chamada à API ou lógica de filtragem de dados
};
</script>

<template>
 <FilterPanel
   :model="filterData"
   label-width="120px"
   :grid-columns="3"
   @visibilityChange="handleVisibilityChange"
   @resetFilters="handleResetFilters"
   @applyFilters="handleApplyFilters"
 >
   <el-form-item label="Nome" prop="name">
     <el-input v-model="filterData.name" placeholder="Digite o nome" />
   </el-form-item>
   <el-form-item label="Região" prop="region">
     <el-input v-model="filterData.region" placeholder="Selecione a região" />
   </el-form-item>
   <el-form-item label="Categoria" prop="category">
     <el-input v-model="filterData.category" placeholder="Escolha a categoria" />
   </el-form-item>
   <el-form-item label="Status" prop="status">
     <el-input v-model="filterData.status" placeholder="Status do item" />
   </el-form-item>
   <el-form-item label="Intervalo de Datas" prop="dateRange">
     <el-input v-model="filterData.dateRange" placeholder="Período" />
   </el-form-item>
   <el-form-item label="Prioridade" prop="priority">
     <el-input v-model="filterData.priority" placeholder="Nível de prioridade" />
   </el-form-item>
   <el-form-item label="Proprietário" prop="owner">
     <el-input v-model="filterData.owner" placeholder="Responsável" />
   </el-form-item>
   <el-form-item label="Data de Criação" prop="creationDate">
     <el-input v-model="filterData.creationDate" placeholder="Data" />
   </el-form-item>
   <el-form-item label="Última Atualização" prop="lastUpdate">
     <el-input v-model="filterData.lastUpdate" placeholder="Data" />
   </el-form-item>
   <el-form-item label="Tags" prop="tags">
     <el-input v-model="filterData.tags" placeholder="Tags associadas" />
   </el-form-item>
 </FilterPanel>
</template>

Tags: Vue.js ElementPlus CSSGrid DynamicCSS FrontendDevelopment

Publicado em 6-5 20:13 por Thomas