Cómo hacer componentes transparentes con Vue

Claramente no me refiero a bajarle la opacity con CSS. Me refiero a hacer un componente que funcione como un elemento que ya conocemos, un <input> por ejemplo. Para esto se necesita que la interfaz del componente sea similar a la del elemento que estamos simulando.

Aquí les cuento un poco sobre tres elementos que nos pueden ayudar para esto.

v-model

Elementos HTML como los <input>, <select> y <textarea> funcionan directamente con v-model para setear two-way data binding. Por esto, si queremos crear un componente que imite la funcionalidad de uno de ellos, es importante que este también permita el uso de esa directiva.

Para esto, nuestro componente debe cumplir dos cosas:

  • Debe recibir una prop de nombre value y hacerle binding a un atributo del mismo nombre en el input
  • Debe emitir en algún momento el evento input con el valor que se quiere

Es decir, el componente debe tener un template que en su forma más simple se vería más o menos así:

<template>
  <input
    :value="value"
    @input="event => $emit('input', event.target.value)"
  >
</template>

$attrs

En Vue, los atributos que se le dan a un componente custom por defecto se aplican al elemento raíz del componente.

Sin embargo, a veces es deseable pasar estos atributos a un componente interno. $attrs tiene la referencia a estos atributos, por lo que podemos usarlo para asignarlos al elemento HTML que queramos, usando v-bind="$attrs".

Sumado a lo anterior, podríamos tener un template para un custom-input que se vea más o menos así:

<template>
  <div>
    ...
    <input
      v-bind="$attrs"
      :value="value"
      @input="event => $emit('input', event.target.value)"
    >
  </div>
</template>

Y que se usa así:

<custom-input
  v-model="someData"
  name="someName"
  autocomplete="off"
/>

En este ejemplo, los atributos name y autocomplete pasan a aplicarse al input gracias a la magia de v-bind="attrs" 🧙‍♂️.

$listeners

Similar a $attrs, pero este tiene la referencia a los eventos que se definieron al instanciar mi componente. Se los podemos asignar a un elemento usando v-on="$listeners".

Importante: hay que tener en cuenta un detalle si se usa junto a lo mencionado de v-model. v-on="$listeners" sobreescribiría el @input definido en nuestro componente. Para que esto no ocurra, se puede definir de la siguiente manera:

v-on="{
  ...this.$listeners,
  input: event => $emit('input', event.target.value),
}"

Con todo lo anterior, el template de nuestro <custom-input> quedaría de la siguiente manera:

<template>
  <div>
    ...
    <input
      v-bind="$attrs"
      :value="value"
      v-on="{
        ...this.$listeners,
        input: event => $emit('input', event.target.value),
      }"
    >
  </div>
</template>

Ahora se le pueden pasar eventos al input:

<custom-input
  v-model="someData"
  name="someName"
  autocomplete="off"
  @hover="doSomething"
/>

Ejemplo

Hasta ahora no he dicho nada que no se pueda ver en los docs de Vue 👀. De hecho, acá explican lo de v-model, aquí lo de $listeners y por último lo de $attrs.

Veamos un ejemplo más concreto para que este post no sea solo una transcripción de esos docs.

Supongamos que tenemos varios inputs en nuestra aplicación y les queremos poner validaciones (indicar que un campo debe tener formato de email, por ejemplo). Además, queremos agregarles un ícono al input para indicar si es válido o tiene algún error.

Usaremos la librería vee-validate, específicamente su componente ValidationProvider.

El código de este componente se podría ver más o menos así, digamos en un validated-input.vue:

<template>
  <ValidationProvider
    tag="div"
    :rules="rules"
    v-slot="{ errors, valid }"
  >
    <input
      :value="value"
      v-bind="$attrs"
      v-on="inputListeners"
    >
    <img
      v-if="errors[0]"
      :src="require('../../assets/images/cross.png')"
    >
    <img
      v-else-if="valid"
      :src="require('../../assets/images/tick.png')"
    />
  </ValidationProvider>
</template>

<script>
export default {
  props: {
    rules: { type: String, required: true },
    value: { type: String, required: true },
  },
  computed: {
    inputListeners() {
      return {
        ...this.$listeners,
        input: event => this.$emit('input', event.target.value),
      };
    },
  },
};
</script>

Luego, lo queremos usar en otro componente. Digamos que queremos que cuando se le haga focus a un input, los demás se vuelvan menos visibles. Para esto podríamos usarlo de esta manera:

<template>
  ...
  <validated-input
    v-model="name"
    placeholder="Nombre"
    rules="required"
    @focus="() => focusedInput = 'name'"
    @blur="() => focusedInput = null"
    :class="{ 'transparent': focusedInput && focusedInput !== 'name' }"
  />
  ...
</template>

<script>
import ValidatedInput from './validated-input';

export default {
  ...
  components: { ValidatedInput },
  data() {
    name: '',
    focusedInput: null,
  }
  ...
}
</script>

<style>
.transparent {
  opacity: .25;
}
</style>

Agregando un poco de css para los colores y posicionamiento, esto se vería más o menos así:

Con esto se puede ver cómo el atributo placeholder y los @focus/@blur se están asociando al <input> dentro de nuestro <validated-input>, así como el v-model va guardando el valor ingresado en name. Todo esto para lograr que nuestro componente sea realmente transparente.