Componente de Input personalizado com Vue

Tutorial para criar componentes de input customizados com Vue

A maioria de nós já enfrentou a seguinte situação: construir um componente de input personalizado. Existem várias razões para isso, mas em geral, o input tem estilos personalizados e devemos ser capazes de reutilizá-lo em nossas aplicações.

Embora possa parecer simples, existem alguns truques e de vez em quando precisamos analisar a documentação (nesse caso, do Vue) para verificar detalhes de implementação. Fica um pouco mais complicado se você não estiver familiarizado com alguns conceitos importantes do Vue.

No mês passado, fevereiro de 2021, aconteceu novamente. Sempre que possível, tento ajudar as pessoas de um grupo do Vue no Slack e esta pergunta surgiu novamente. Não exatamente esta questão, mas uma pessoa estava com problemas para construir um componente de input personalizado. O problema estava relacionado ao entendimento de alguns conceitos.

Para consolidar esse conhecimento para mim mesmo e usá-lo como algum tipo de documentação para outras pessoas, decidi compilar o processo de escrever um input personalizado.


Índice

  • v-model e input

  • O componente de input personalizado errado

  • O componente de input personalizado feliz

  • Adicionando validação

  • Combinando computed e v-model

  • Extra: a propriedade model

  • E agora?

v-model e <input>

Depois de começar a construir formulários com o Vue, aprendemos a diretiva v-model.
A diretiva faz um grande trabalho para nós: vincula um valor a um input. Isso significa que sempre que alterarmos o valor do input, a variável também será atualizada.

Os documentos oficiais explicam como funciona: https://vuejs.org/v2/guide/forms.html

Resumindo, podemos seguir o seguinte modelo:

<template>
<label>
Username
<input type="text" name="username" v-model="username">
</label>
</template>
<script>
export default {
name: 'UsernameInput',
data() {
return {
username: 'Initial value',
};
},
}
</script>
view raw custom-input.js hosted with ❤ by GitHub

Teremos um input que tem Initial value como valor inicial e os dados de username serão atualizados automaticamente assim que alterarmos o valor do input.

O problema com o componente acima é que não podemos reutilizá-lo. Imagine que temos uma página onde precisamos do nome de usuário (username) e do e-mail. O componente acima não vai saber lidar com o caso do e-mail porque os dados estão dentro do próprio componente, não em outro lugar (como o componente pai, por exemplo) . É aí que os componentes de input customizados brilham.

O componente de input personalizado errado

Bem, mas por que estou mostrando este exemplo? A resposta é: esta é a primeira abordagem que a maioria de nós tentará.

Vamos ver como vamos usar nosso componente de input personalizado:

<!-- App.vue -->
<template>
<custom-input :label="label" v-model="model" />
</template>
<script>
import CustomInput from './components/CustomInput.ue';
export default {
name: 'App',
components: { CustomInput },
data() {
return {
label: 'Username',
model: '',
};
},
}
</script>
view raw custom-input-1.js hosted with ❤ by GitHub

Neste caso, o input personalizado espera um label e um v-model e será semelhante ao componente abaixo:

<!-- CustomInput.vue -->
<template>
<label>
{{ label }}
<input type="text" :name="name" v-model="value" />
</label>
</template>
<script>
export default {
name: 'CustomInput',
props: {
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
},
computed: {
name() {
return this.label.toLowerCase();
},
},
}
</script>
view raw custom-input-2.js hosted with ❤ by GitHub

Primeiro, é esperado um label como propriedade e logo após calcula o name em cima disso (também pode ser uma propriedade).
Em segundo lugar, é esperado a propriedade value e acontece o vínculo ao input por meio de v-model. A razão por trás disso pode ser encontrada na documentação, mas, em resumo, quando usamos o v-model em um componente personalizado, ele obterá o value como uma propriedade que é o valor da variável do v-model utilizado. Em nosso exemplo, será o valor do nosso modelo definido em App.vue.

Se tentarmos o código acima, ele funcionará conforme o esperado, mas por que está errado? Se abrirmos o console, veremos algo assim:

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value" 

O aviso exibido é que estamos alterando uma propriedade. A maneira como o Vue funciona é: o componente filho tem propriedades que vieram do componente pai e o componente filho emite alterações para o componente pai. Usar o v-model com a propriedade value que obtivemos do componente pai viola a regra de imutabilidade.

Outra maneira de ver esse problema é reescrever o App.vue assim:

<!-- App.vue -->
<template>
<custom-input :label="label" :value="model" />
</template>
...
view raw custom-input-3.js hosted with ❤ by GitHub

A principal diferença está no uso de :value no lugar de v-model. Nesse caso, estamos apenas passando o model para a propriedade value. O exemplo ainda funciona e recebemos a mesma mensagem no console.

A próxima etapa é melhorar o exemplo acima e verificar se ele funciona conforme o esperado.

O componente de input personalizado feliz

O componente de input personalizado feliz não altera seu prop, mas emite as alterações para o componente pai.

A documentação possui exatamente esse exemplo, mas iremos um pouco mais além aqui. Se seguirmos a documentação, nosso CustomInput deve ser semelhante a este abaixo:

<!-- CustomInput.vue -->
<template>
<label>
{{ label }}
<input type="text" :name="name" :value="value" @input="$emit('input', $event.target.value)" />
</label>
</template>
<script>
export default {
name: 'CustomInput',
props: {
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
},
computed: {
name() {
return this.label.toLowerCase();
},
},
}
</script>
view raw custom-input-4.js hosted with ❤ by GitHub

Isso é o suficiente para fazer funcionar. Podemos até mesmo testá-lo em relação ao App.vue, aquele que estava usando o v-model, onde tudo funciona como esperado, e também aquele que usa somente :value, onde não funciona quando paramos de alterar a propriedade.

Adicionando validação

No caso de precisarmos fazer algo quando o dado muda, por exemplo verificar se ele está vazio e mostrar alguma mensagem de erro, é necessário extrair o emit. Teremos as seguintes alterações em nosso componente:

<!-- CustomInput.vue -->
<template>
...
<input type="text" :name="name" :value="value" @input="onInput" />
...
</template>
<script>
...
methods: {
onInput(event) {
this.$emit('input', event.target.value);
}
}
...
</script>
view raw custom-input-5.js hosted with ❤ by GitHub

Agora adicionamos a validação para dados vazios:

<!-- CustomInput.vue -->
<template>
...
<p v-if="error">{{ error }}</p>
...
</template>
<script>
...
data() {
return {
error: '',
};
},
...
onInput(event) {
const value = event.target.value;
if (!value) {
this.error = 'Value should not be empty';
}
this.$emit('input', event.target.value)
}
...
</script>
view raw custom-input-6.js hosted with ❤ by GitHub

Isso meio que funciona, primeiro não é exibido nenhum erro e se digitarmos e excluirmos, será exibida a mensagem de erro. O problema é que a mensagem de erro nunca desaparece. Para corrigir isso, precisamos adicionar um watcher ao valor da propriedade e limpar a mensagem de erro sempre que ela for atualizada.

<!-- CustomInput.vue -->
...
<script>
...
watch: {
value: {
handler(value) {
if (value) {
this.error = '';
}
},
},
},
...
</script>
view raw custom-input-7.js hosted with ❤ by GitHub

Poderíamos ter um resultado semelhante adicionando um else dentro de onInput. Usar o watcher nos permite validar antes que o usuário atualize o valor do input, se desejável.

Se adicionarmos mais coisas, provavelmente iremos expandir este componente ainda mais e as coisas estarão espalhadas por todo o bloco <script>. Para agrupar as coisas um pouco, podemos tentar uma abordagem diferente: usar computed junto com o v-model.

Combinando computed e v-model

Ao invés de adicionar um listener ao input e depois emiti-lo novamente, podemos aproveitar o poder do v-model e do computed. É o mais próximo que podemos chegar da abordagem errada, mas ainda assim acertar 😅

Vamos reescrever nosso componente:

<!-- CustomInput.vue -->
<template>
...
<input type="text" :name="name" v-model="model" />
...
</template>
<script>
...
computed: {
...
model: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
},
...
</script>
view raw custom-input-8.js hosted with ❤ by GitHub

Podemos nos livrar do método onInput e também do watcher, pois podemos lidar com tudo nas funções get/set da propriedade computed.

Uma coisa legal que podemos conseguir com isso é o uso de modificadores, como .trim/number que precisariam ser escritos manualmente antes.

Esta é uma boa abordagem para componentes de input simples. As coisas podem ficar um pouco mais complexas e esta abordagem não cumpre todos os casos de uso, se for esse o caso, precisamos ir para binding de valor e listeners de eventos. Um bom exemplo é se você quiser suportar o modificador .lazy no componente pai. Nesse caso você precisará manualmente adicionar listeners ao input.

Extra: a propriedade model

A propriedade model permite que você personalize o comportamento do v-model. Você pode especificar qual propriedade será mapeada, o padrão é value.
Também é possível customizar qual evento será emitido. O padrão nesse caso é input ou change quando .lazy é usado.

Isso é especialmente útil se você deseja usar a propriedade value para outra coisa, pois pode fazer mais sentido para um contexto específico, ou se apenas deseja tornar as coisas mais explícitas e renomear o value para model, por exemplo. Na maioria dos casos, podemos usá-lo para personalizar checkboxes/radios ao receber objetos como input.

E agora?

Depende de quão complexo seu input personalizado precisa ser:

  • O input foi criado para centralizar os estilos em um componente e sua API está praticamente em cima da API do Vue: computed + v-model. Isso se encaixa muito bem em nosso exemplo, tem propriedades simples e nenhuma validação complexa.

    <!-- CustomInput.vue -->
    <template>
    <label>
    {{ label }}
    <input type="text" :name="name" v-model="model" />
    </label>
    </template>
    <script>
    export default {
    name: 'CustomInput',
    props: {
    label: {
    type: String,
    required: true,
    },
    value: {
    type: String,
    required: true,
    },
    },
    computed: {
    name() {
    return this.label.toLowerCase();
    },
    model: {
    get() {
    return this.value;
    },
    set(value) {
    this.$emit('input', value);
    },
    },
    },
    }
    </script>
    view raw custom-input-9.js hosted with ❤ by GitHub
  • Todo o resto (o que significa que você precisa ajustar muito a configuração anterior para suportar o que você precisa): listeners, watchers e o que mais você precisar. Ele pode ter vários estados (pense na validação assíncrona em que um estado de carregamento pode ser útil) ou você deseja oferecer suporte ao modificador .lazy do componente pai, são bons exemplos para evitar a primeira abordagem.

    <!-- CustomInput.vue -->
    <template>
    <label>
    {{ label }}
    <input type="text" :name="name" :value="value" @input="onInput" @change="onChange" />
    </label>
    </template>
    <script>
    export default {
    name: 'CustomInput',
    props: {
    label: {
    type: String,
    required: true,
    },
    value: {
    type: String,
    required: true,
    },
    },
    /* Can add validation here
    watch: {
    value: {
    handler(newValue, oldValue) {
    },
    },
    }, */
    computed: {
    name() {
    return this.label.toLowerCase();
    },
    },
    methods: {
    onInput(event) {
    // Can add validation here
    this.$emit('input', event.target.value);
    },
    onChange(event) { // Supports .lazy
    // Can add validation here
    this.$emit('change', event.target.value);
    },
    },
    }
    </script>
    view raw custom-input-10.js hosted with ❤ by GitHub

    Este texto foi originalmente escrito por Vinicius Kiatkoski Neves e sua tradução para o português e publicação no site da BrazilJS foi previamente autorizada pelo autor.
    Link para o post original em inglês: https://dev.to/viniciuskneves/vue-custom-input-bk8