DEV Community

John Au-Yeung
John Au-Yeung

Posted on

How to Add Tool Tips to Your Vue.js App

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

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

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:

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:

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

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

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:

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:

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

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

Top comments (0)