A ripple effect is a situation where something starts inward and move outward.
A ripple effect is a situation where something starts inward and move outward. In a web app, this is the situation where the color changes from the inside and then spreads outward. You can add the ripple effect to your Vue.js app with the Vue-Ripple-Directive. More details about it are located at https://github.com/PygmySlowLoris/vue-ripple-directive.
In this article, we will make a grocery list app where users can search for dishes they want to cook with the MealDB API and add the ingredients they want from it. We will add ripple effects to buttons and list items to highlight them when the mouse pointer goes over them.
To start building the app, we run the Vue CLI by running npx @vue/cli grocery-app
. In the wizard, select ‘Manually select features’, then select Babel, Vuex, Vue Router, and CSS Preprocessor. Next, we install some packages that we need. We need Axios for making HTTP requests, BootstrapVue for styling, Vue Font Awesome for adding icons, Vee-Validate for form validation and Vue Ripple Directive for adding the ripple effect. To install them, we run:
npm i axios @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/vue-fontawesome axios bootstrap-vue vee-validate vue-ripple-directive
With all the packages installed, we can start writing the app. We start by adding a form for adding groceries. Create a GroceryForm.vue
file in the components folder and add:
<template>
<div>
<ValidationObserver ref="observer" v-slot="{ invalid }">
<b-form @submit.prevent="onSubmit" novalidate>
<b-form-group label="Search for Dish and Add Ingredients From Result">
<ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
<b-form-input
type="text"
:state="errors.length == 0"
v-model="form.keyword"
required
placeholder="Search for Dish and Add Ingredients From Result"
name="keyword"
></b-form-input>
<b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
</ValidationProvider>
</b-form-group>
<b-button v-ripple.mouseover.500 type="submit" variant="primary">Find Dishes</b-button>
</b-form>
</ValidationObserver>
<b-card v-for="(m, i) in meals" :title="m.strMeal" :key="i">
<b-card-text>
<b-list-group>
<b-list-group-item
v-for="(key, index) in Object.keys(m).filter(k => k.includes('strIngredient') && m[k])"
:key="index"
v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
>{{m[key]}}</b-list-group-item>
</b-list-group>
</b-card-text>
<b-button
v-ripple.mouseover.500
variant="primary"
@click="addToGroceryList(i)"
>Add Ingredients to Grocery List</b-button>
</b-card>
<h4>Your Chosen Ingredients</h4>
<b-list-group>
<b-list-group-item v-for="(ingredient, i) of ingredients" :key="i" v-ripple.mouseover>
{{ingredient}}
<font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
</b-list-group-item>
</b-list-group>
<br />
<b-button v-ripple.mouseover.500 type="button" variant="primary" @click="saveGroceryList()">Save</b-button>
<b-button v-ripple.mouseover.500 type="reset" variant="danger" @click="cancel()">Cancel</b-button>
</div>
</template>
<script>
import { requestsMixin } from "@/mixins/requestsMixin";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
export default {
name: "GroceryForm",
mixins: [requestsMixin],
components: {
faTimes
},
data() {
return {
form: {},
meals: [],
ingredients: []
};
},
computed: {
grocery() {
return this.$store.state.grocery;
}
},
methods: {
cancel() {
this.$emit("cancelled");
},
async onSubmit() {
const isValid = await this.$refs.observer.validate();
if (!isValid) {
return;
}
const { data } = await this.findDishes(this.form.keyword);
this.meals = data.meals;
},
addToGroceryList(index) {
const meal = this.meals[index];
const keys = Object.keys(meal).filter(
k => k.includes("strIngredient") && meal[k]
);
const ingredients = keys.map(k => meal[k]);
this.ingredients = Array.from(
new Set(this.ingredients.concat(ingredients))
);
},
removeIngredient(index) {
this.ingredients.splice(index, 1);
},
async saveGroceryList() {
const payload = { ingredients: this.ingredients };
if (!this.groceryListId) {
await this.addGrocery(payload);
} else {
await this.editGrocery(payload);
}
const { data } = await this.getGrocery();
this.$store.commit("setGrocery", data);
this.$emit("saved");
}
},
watch: {
grocery: {
handler(val) {
this.ingredients = val.ingredients || [];
},
deep: true,
immediate: true
}
}
};
</script>
<style lang="scss" scoped>
.delete {
cursor: pointer;
}
</style>
This form lets users search for dishes with the given keyword, then return a list of ingredients for the dishes and then the user can add them to a list with the duplicates removed. 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. In the b-form-input
components. We also add Vee-Validate validation to make sure that users have filled out the date before submitting it. We make the keyword
field required in the rules
prop so that users will have to enter something before searching.
We have buttons in the list at the bottom of the form, which has the list of ingredients, to delete each of them. This is why we imported the faTimes
icon here, which displays as an ‘x’, so that users can click on it and delete it. If that element is clicked, the removeIngredient
function is called. Then the user clicks Save at the bottom of the form, then the saveGroceryList
function is called, which saves the list to our back end.
In this component, we also have a watch
block to watch the grocery
value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the grocery
value is updated.
We have the ripple effect applied to the buttons and the list rows with Vue Ripple. v-ripple.mouseover.500
means that the ripple effect will show for 500 milliseconds when the mouse is over the element with this directive. To apply a different color to the ripple effect than the default we can also specify the color value in the parameter of the directive as we have in v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’”
. The ripple will have the color specified.
We style the element for deleting the form with cursor:pointer
so the mouse icon will show up a hand instead of the arrow.
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 MEAL_DB_URL = "https://www.themealdb.com/api/json/v1/1/search.php?s=";
const axios = require("axios");
export const requestsMixin = {
methods: {
getGrocery() {
return axios.get(`${APIURL}/grocery`);
},
addGrocery(data) {
return axios.post(`${APIURL}/grocery`, data);
},
editGrocery(data) {
return axios.put(`${APIURL}/grocery`, data);
},
findDishes(keyword) {
return axios.get(`${MEAL_DB_URL}${keyword}`);
}
}
};
These are the functions we use in our components to make HTTP requests to get and save our grocery data and search the Meal DB API for dishes.
Next in Home.vue
, replace the existing code with:
<template>
<div class="page">
<h1 class="text-center">Grocery List</h1>
<b-button-toolbar class="button-toolbar">
<b-button
v-ripple.mouseover.500
@click="openAddModal()"
variant="primary"
>Add Ingredients to Grocery List</b-button>
</b-button-toolbar>
<h4>Your Grocery List</h4>
<b-list-group>
<b-list-group-item
v-for="(ingredient, i) of grocery.ingredients"
:key="i"
v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
>
{{ingredient}}
<font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
</b-list-group-item>
</b-list-group>
<b-modal id="add-modal" title="Add Ingredients to Grocery List" hide-footer>
<GroceryForm
@saved="closeModal()"
@cancelled="closeModal()"
:edit="false"
:groceryListId="grocery.id"
/>
</b-modal>
</div>
</template>
<script>
// @ is an alias to /src
import GroceryForm from "@/components/GroceryForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "home",
components: {
GroceryForm
},
mixins: [requestsMixin],
computed: {
grocery() {
return this.$store.state.grocery;
}
},
data() {
return {
ingredients: []
};
},
beforeMount() {
this.getGroceryList();
},
methods: {
openAddModal() {
this.$bvModal.show("add-modal");
},
closeModal() {
this.$bvModal.hide("add-modal");
},
async getGroceryList() {
const { data } = await this.getGrocery();
this.$store.commit("setGrocery", data);
},
async removeIngredient(index) {
this.ingredients.splice(index, 1);
const payload = { id: this.grocery.id, ingredients: this.ingredients };
await this.editGrocery(payload);
const { data } = await this.getGrocery();
this.$store.commit("setGrocery", data);
}
},
watch: {
grocery: {
handler(val) {
this.ingredients = val.ingredients || [];
},
deep: true,
immediate: true
}
}
};
</script>
};
This is the component of the home page. We display the list of ingredients chosen obtained from our back end here. Also, we have a button to open a modal with the GroceryForm
that we created earlier to add ingredients to our grocery list. Getting data is done in the getGroceryList
function. We put the obtained data into our Vuex store in the last line of the function.
Also, we let users remove ingredients that they saved to the list in this page with the removeIngredient
function. We call splice
on the this.ingredients
array, which we got from the grocery
state in the store then set to the current value in the handler
of the watch
block of grocery
.
Again, we have the ripple effect applied to the buttons and the list rows with Vue Ripple. v-ripple.mouseover.500
to show the ripple effect for 500 milliseconds for the buttons and v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’”
. The ripple will have the color specified in the list items like we did in GroceryForm
.
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="/">Grocery List 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;
}
button,
.btn.btn-primary {
margin-right: 10px !important;
}
.button-toolbar {
margin-bottom: 10px;
}
</style>
to add a Bootstrap navigation bar to the top of our pages, and a router-view
to display the routes we define. This style
section isn’t scoped so the styles will apply globally. In the .page
selector, we add some padding to our pages. We add some padding to the buttons in the remaining style
code.
Then in main.js
, 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 "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value, max_value } from "vee-validate/dist/rules";
import Ripple from "vue-ripple-directive";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
library.add(faTimes);
Vue.component("font-awesome-icon", FontAwesomeIcon);
Vue.directive("ripple", Ripple);
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);
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, the Vue-Ripple library, and the Vue Font Awesome packages here. The faTimes
is added to our app with the library.add
function of Vue Font Awesome so that we can use it in our app.
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: {
grocery: {}
},
mutations: {
setGrocery(state, payload) {
state.grocery = payload;
}
},
actions: {}
});
to add our grocery state to the store so we can observe it in the computed
block of GroceryForm
and HomePage
components. We have the setGrocery
function to update the grocery
state and we use it in the components by call this.$store.commit(“setGrocery”, data);
like we did in GroceryForm
and HomePage
.
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>Grocery List App</title>
</head>
<body>
<noscript>
<strong
>We're sorry but vue-ripple-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 of our app.
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:
{
"`grocery`": {}
}
So we have the grocery
endpoints defined in the requests.js
available.