How to Add Tool Tips to Your Vue.js App
Tags: vue, webdev, javascript, tutorial
Published: 2020-01-18 03:24:25

**Subscribe to my email list now at http://jauyeung.net/subscribe/**

**Follow me on Twitter at https://twitter.com/AuMayeung**

**Many more articles at https://medium.com/@hohanga**

Tooltips are common for providing hints on how to use different parts of a web app. It is easy to add and it helps users understand the app more. They’re also useful for display long text that would be too long.

In Vue.js, adding tooltips is easy with the V-Tooltip directive, located at [https://github.com/Akryum/v-tooltip](https://github.com/Akryum/v-tooltip). It is a directive for configurable tooltips. You can change the color, text, delay in displaying, and many other options associated with tooltips.

In this article, we will build a recipe app that has tooltips to guide users on how to add recipes into a form. Users can enter the name of their dish, the ingredients, the steps and upload a photo. We will build the app with Vue.js

We start building the app by running the Vue CLI. We run it by entering:

```
npx @vue/cli create recipe-app
```

Then select ‘Manually select features’. Next, we select Babel, Vue Router, Vuex, and CSS Preprocessor in the list. After that, we install a few packages. We will install Axios for making HTTP requests to our back end. BootstrapVue for styling, V-Tooltip for the tooltips, and Vee-Validate for form validation. We install the packages by running `npm i axios bootstrap-vue v-tooltip vee-validate` .

Now we move on to creating the components. Create a file called `RecipeForm.vue` in the `components` folder and add:

```javascript
<template>  
  <ValidationObserver ref="observer" v-slot="{ invalid }">  
    <b-form @submit.prevent="onSubmit" novalidate>  
      <b-form-group  
        label="Name"  
        v-tooltip="{  
          content: 'Enter Your Recipe Name Here',  
          classes: ['info'],  
          targetClasses: ['it-has-a-tooltip'],  
        }"  
      >  
        <ValidationProvider name="name" rules="required" v-slot="{ errors }">  
          <b-form-input  
            type="text"  
            :state="errors.length == 0"  
            v-model="form.name"  
            required  
            placeholder="Name"  
            name="name"  
          ></b-form-input>  
          <b-form-invalid-feedback :state="errors.length == 0">Name is requied.</b-form-invalid-feedback>  
        </ValidationProvider>  
      </b-form-group>
      <b-form-group  
        label="Ingredients"  
        v-tooltip="{  
          content: 'Enter Your Recipe Description Here',  
          classes: ['info'],  
          targetClasses: ['it-has-a-tooltip'],  
        }"  
      >  
        <ValidationProvider name="ingredients" rules="required" v-slot="{ errors }">  
          <b-form-textarea  
            :state="errors.length == 0"  
            v-model="form.ingredients"  
            required  
            placeholder="Ingredients"  
            name="ingredients"  
            rows="8"  
          ></b-form-textarea>  
          <b-form-invalid-feedback :state="errors.length == 0">Ingredients is requied.</b-form-invalid-feedback>  
        </ValidationProvider>  
      </b-form-group>
      <b-form-group  
        label="Recipe"  
        v-tooltip="{  
          content: 'Enter Your Recipe Here',  
          classes: ['info'],  
          targetClasses: ['it-has-a-tooltip'],  
        }"  
      >  
        <ValidationProvider name="recipe" rules="required" v-slot="{ errors }">  
          <b-form-textarea  
            :state="errors.length == 0"  
            v-model="form.recipe"  
            required  
            placeholder="Recipe"  
            name="recipe"  
            rows="15"  
          ></b-form-textarea>  
          <b-form-invalid-feedback :state="errors.length == 0">Recipe is requied.</b-form-invalid-feedback>  
        </ValidationProvider>  
      </b-form-group>
      <b-form-group label="Photo">  
        <input type="file" style="display: none" ref="file" @change="onChangeFileUpload($event)" />  
        <b-button  
          @click="$refs.file.click()"  
          v-tooltip="{  
            content: 'Upload Photo of Your Dish Here',  
            classes: ['info'],  
            targetClasses: ['it-has-a-tooltip'],  
          }"  
        >Upload Photo</b-button>  
      </b-form-group><img ref="photo" :src="form.photo" class="photo" />
       <br />
      <b-button type="submit" variant="primary" style="margin-right: 10px">Submit</b-button>  
      <b-button type="reset" variant="danger" @click="cancel()">Cancel</b-button>  
    </b-form>  
  </ValidationObserver>  
</template>

<script>  
import { requestsMixin } from "@/mixins/requestsMixin";

export default {  
  name: "RecipeForm",  
  mixins: [requestsMixin],  
  props: {  
    edit: Boolean,  
    recipe: Object  
  },  
  methods: {  
    async onSubmit() {  
      const isValid = await this.$refs.observer.validate();  
      if (!isValid || !this.form.photo) {  
        return;  
      }
      if (this.edit) {  
        await this.editRecipe(this.form);  
      } else {  
        await this.addRecipe(this.form);  
      }  
      const { data } = await this.getRecipes();  
      this.$store.commit("setRecipes", data);  
      this.$emit("saved");  
    },  
    cancel() {  
      this.$emit("cancelled");  
    },  
    onChangeFileUpload($event) {  
      const file = $event.target.files[0];  
      const reader = new FileReader();  
      reader.onload = () => {  
        this.$refs.photo.src = reader.result;  
        this.form.photo = reader.result;  
      };  
      reader.readAsDataURL(file);  
    }  
  },  
  data() {  
    return {  
      form: {}  
    };  
  },  
  watch: {  
    recipe: {  
      handler(val) {  
        this.form = JSON.parse(JSON.stringify(val || {}));  
      },  
      deep: true,  
      immediate: true  
    }  
  }  
};  
</script>

<style>  
.photo {  
  width: 100%;  
  margin-bottom: 10px;  
}  
</style>
```

In this file, we have a form to let users enter their recipe. We have text inputs and a file upload file to let users upload a photo. We use Vee-Validate to validate our inputs. We use the `ValidationObserver` component to watch for the validity of the form inside the component and `ValidationProvider` to check for the validation rule of the inputted value of the input inside the component. Inside the `ValidationProvider`, we have our BootstrapVue input for the text input fields.

Each form field has a tooltip with additional instructions. The `v-tooltip` directive is provided by the V-Tooltip library. We set the content of the tooltip and the classes here, and we can set other options like delay in displaying, the position and the background color of the tooltip. A full list of options is available at [https://github.com/Akryum/v-tooltip](https://github.com/Akryum/v-tooltip).

The photo upload works by letting users open the file upload dialog with the Upload Photo button. The button would click on the hidden file input when the Upload Photo button is clicked. After the user selects a file, then the `onChangeFileUpload` function is called. In this function, we have the `FileReader` object which sets the `src` attribute of the `img` tag to show the uploaded image, and also the `this.form.photo` field. `readAsDataUrl` reads the image into a string so we can submit it without extra effort.

This form is also used for editing recipes, so we have a `watch` block to watch for the `recipe` prop, which we will pass into this component when there is something to be edited.

Next, we create a `mixins` folder and add `requestsMixin.js` into the `mixins` folder. In the file, we add:

```javascript
const APIURL = "http://localhost:3000";  
const axios = require("axios");export const requestsMixin = {  
  methods: {  
    getRecipes() {  
      return axios.get(`${APIURL}/recipes`);  
    },

    addRecipe(data) {  
      return axios.post(`${APIURL}/recipes`, data);  
    },

    editRecipe(data) {  
      return axios.put(`${APIURL}/recipes/${data.id}`, data);  
    },

    deleteRecipe(id) {  
      return axios.delete(`${APIURL}/recipes/${id}`);  
    }  
  }  
};
```

These are the functions we use in our components to make HTTP requests to get and save our data.

Next in `Home.vue` , replace the existing code with:

```javascript
<template>  
  <div class="page">  
    <h1 class="text-center">Recipes</h1>  
    <b-button-toolbar class="button-toolbar">  
      <b-button @click="openAddModal()" variant="primary">Add Recipe</b-button>  
    </b-button-toolbar>
    <b-card  
      v-for="r in recipes"  
      :key="r.id"  
      :title="r.name"  
      :img-src="r.photo"  
      img-alt="Image"  
      img-top  
      tag="article"  
      class="recipe-card"  
      img-bottom  
    >  
      <b-card-text>  
        <h1>Ingredients</h1>  
        <div class="wrap">{{r.ingredients}}</div>  
      </b-card-text><b-card-text>  
        <h1>Recipe</h1>  
        <div class="wrap">{{r.recipe}}</div>  
      </b-card-text>
      <b-button @click="openEditModal(r)" variant="primary">Edit</b-button>
      <b-button @click="deleteOneRecipe(r.id)"  variant="danger">Delete</b-button>  
    </b-card>
     <b-modal id="add-modal" title="Add Recipe" hide-footer>  
      <RecipeForm @saved="closeModal()" @cancelled="closeModal()" :edit="false" />  
    </b-modal>
    <b-modal id="edit-modal" title="Edit Recipe" hide-footer>  
      <RecipeForm  
        @saved="closeModal()"  
        @cancelled="closeModal()"  
        :edit="true"  
        :recipe="selectedRecipe"  
      />  
    </b-modal>  
  </div>  
</template>

<script>  
// @ is an alias to /src  
import RecipeForm from "@/components/RecipeForm.vue";  
import { requestsMixin } from "@/mixins/requestsMixin";

export default {  
  name: "home",  
  components: {  
    RecipeForm  
  },  
  mixins: [requestsMixin],  
  computed: {  
    recipes() {  
      return this.$store.state.recipes;  
    }  
  },  
  beforeMount() {  
    this.getAllRecipes();  
  },  
  data() {  
    return {  
      selectedRecipe: {}  
    };  
  },  
  methods: {  
    openAddModal() {  
      this.$bvModal.show("add-modal");  
    },  
    openEditModal(recipe) {  
      this.$bvModal.show("edit-modal");  
      this.selectedRecipe = recipe;  
    },  
    closeModal() {  
      this.$bvModal.hide("add-modal");  
      this.$bvModal.hide("edit-modal");  
      this.selectedRecipe = {};  
    },  
    async deleteOneRecipe(id) {  
      await this.deleteRecipe(id);  
      this.getAllRecipes();  
    },  
    async getAllRecipes() {  
      const { data } = await this.getRecipes();  
      this.$store.commit("setRecipes", data);  
    }  
  }  
};  
</script>

<style scoped>  
.recipe-card {  
  width: 95vw;  
  margin: 0 auto;  
  max-width: 700px;  
}

.wrap {  
  white-space: pre-wrap;  
}  
</style>
```

In this file, we have a list of BootstrapVue cards to display a list of recipe entries and let users open and close the add and edit modals. We have buttons in each card to let users edit or delete each entry. Each card has an image of the recipe at the bottom which was uploaded when the recipe is entered.

In the `scripts` section, we have the `beforeMount` hook to get all the password entries during page load with the `getRecipes` function we wrote in our mixin. When the Edit button is clicked, the `selectedRecipe` variable is set, and we pass it to the `RecipeForm` for editing.

To delete a recipe, we call `deleteRecipe` in our mixin to make the request to the back end.

The CSS in the `wrap` class is for rendering line break characters as line breaks.

Next in `App.vue` , we replace the existing code with:

```javascript
<template>  
  <div id="app">  
    <b-navbar toggleable="lg" type="dark" variant="info">  
      <b-navbar-brand to="/">Recipes App</b-navbar-brand><b-navbar-toggle target="nav-collapse"></b-navbar-toggle><b-collapse id="nav-collapse" is-nav>  
        <b-navbar-nav>  
          <b-nav-item to="/" :active="path  == '/'">Home</b-nav-item>  
        </b-navbar-nav>  
      </b-collapse>  
    </b-navbar>  
    <router-view />  
  </div>  
</template><script>  
export default {  
  data() {  
    return {  
      path: this.$route && this.$route.path  
    };  
  },  
  watch: {  
    $route(route) {  
      this.path = route.path;  
    }  
  }  
};  
</script>

<style lang="scss">  
.page {  
  padding: 20px;  
  margin: 0 auto;  
  max-width: 700px;  
}

button {  
  margin-right: 10px !important;  
}

.button-toolbar {  
  margin-bottom: 10px;  
}

.tooltip {  
  display: block !important;  
  z-index: 10000;.tooltip-inner {  
    background: black;  
    color: white;  
    border-radius: 16px;  
    padding: 5px 10px 4px;  
  }

  .tooltip-arrow {  
    width: 0;  
    height: 0;  
    border-style: solid;  
    position: absolute;  
    margin: 5px;  
    border-color: black;  
  }

  &[x-placement^="top"] {  
    margin-bottom: 5px;.tooltip-arrow {  
      border-width: 5px 5px 0 5px;  
      border-left-color: transparent !important;  
      border-right-color: transparent !important;  
      border-bottom-color: transparent !important;  
      bottom: -5px;  
      left: calc(50% - 5px);  
      margin-top: 0;  
      margin-bottom: 0;  
    }  
  }

  &[x-placement^="bottom"] {  
    margin-top: 5px;.tooltip-arrow {  
      border-width: 0 5px 5px 5px;  
      border-left-color: transparent !important;  
      border-right-color: transparent !important;  
      border-top-color: transparent !important;  
      top: -5px;  
      left: calc(50% - 5px);  
      margin-top: 0;  
      margin-bottom: 0;  
    }  
  }

  &[x-placement^="right"] {  
    margin-left: 5px;.tooltip-arrow {  
      border-width: 5px 5px 5px 0;  
      border-left-color: transparent !important;  
      border-top-color: transparent !important;  
      border-bottom-color: transparent !important;  
      left: -5px;  
      top: calc(50% - 5px);  
      margin-left: 0;  
      margin-right: 0;  
    }  
  }

  &[x-placement^="left"] {  
    margin-right: 5px;.tooltip-arrow {  
      border-width: 5px 0 5px 5px;  
      border-top-color: transparent !important;  
      border-right-color: transparent !important;  
      border-bottom-color: transparent !important;  
      right: -5px;  
      top: calc(50% - 5px);  
      margin-left: 0;  
      margin-right: 0;  
    }  
  }

  &[aria-hidden="true"] {  
    visibility: hidden;  
    opacity: 0;  
    transition: opacity 0.15s, visibility 0.15s;  
  }

  &[aria-hidden="false"] {  
    visibility: visible;  
    opacity: 1;  
    transition: opacity 0.15s;  
  }  
}  
</style>
```

to add a Bootstrap navigation bar to the top of our pages, and a `router-view` to display the routes we define. Also, we have the V-Tooltip styles in the `style` section. This `style` section isn’t scoped so the styles will apply globally. In the `.page` selector, we add some padding to our pages and set `max-width` to 700px so that the cards won’t be too wide. We also added some margins to our buttons.

Next in `main.js` , we replace the existing code with:

```javascript
import Vue from "vue";  
import App from "./App.vue";  
import router from "./router";  
import store from "./store";  
import BootstrapVue from "bootstrap-vue";  
import VTooltip from "v-tooltip";  
import "bootstrap/dist/css/bootstrap.css";  
import "bootstrap-vue/dist/bootstrap-vue.css";  
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";  
import { required } from "vee-validate/dist/rules";  
extend("required", required);  
Vue.component("ValidationProvider", ValidationProvider);  
Vue.component("ValidationObserver", ValidationObserver);  
Vue.use(BootstrapVue);  
Vue.use(VTooltip);

Vue.config.productionTip = false;

new Vue({  
  router,  
  store,  
  render: h => h(App)  
}).$mount("#app");
```

We added all the libraries we need here, including BootstrapVue JavaScript and CSS, Vee-Validate components along with the validation rules, and the V-Tooltip directive we used in the components.

In `router.js` we replace the existing code with:

```javascript
import Vue from "vue";  
import Router from "vue-router";  
import Home from "./views/Home.vue";

Vue.use(Router);

export default new Router({  
  mode: "history",  
  base: process.env.BASE_URL,  
  routes: [  
    {  
      path: "/",  
      name: "home",  
      component: Home  
    }  
  ]  
});
```

to include the home page in our routes so users can see the page.

And in `store.js` , we replace the existing code with:

```javascript
import Vue from "vue";  
import Vuex from "vuex";Vue.use(Vuex);export default new Vuex.Store({  
  state: {  
    recipes: []  
  },  
  mutations: {  
    setRecipes(state, payload) {  
      state.recipes = payload;  
    }  
  },  
  actions: {}  
});
```

to add our `recipes` state to the store so we can observe it in the `computed` block of `RecipeForm`and `HomePage` components. We have the `setRecipes` function to update the `passwords` state and we use it in the components by call `this.$store.commit(“setRecipes”, response.data);` like we did in `RecipeForm` .

Finally, in `index.html` , we replace the existing code with:

```html
<!DOCTYPE html>  
<html lang="en">  
  <head>  
    <meta charset="utf-8" />  
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />  
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />  
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />  
    <title>Recipe App</title>  
  </head>  
  <body>  
    <noscript>  
      <strong  
        >We're sorry but vue-tooltip-tutorial-app doesn't work properly without  
        JavaScript enabled. Please enable it to continue.</strong  
      >  
    </noscript>  
    <div id="app"></div>  
    <!-- built files will be auto injected -->  
  </body>  
</html>
```

to change the title.

After all the hard work, we can start our app by running `npm run serve`.

To start the back end, we first install the `json-server` package by running `npm i json-server`. Then, go to our project folder and run:

```
json-server --watch db.json
```

In `db.json`, change the text to:

```
{  
  "recipes": [  
  ]  
}
```

So we have the `recipes` endpoints defined in the `requests.js` available.