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