Categories
Vue

How to Add Ripple Effect to UI Elements in Your Vue.js App

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.

Categories
JavaScript

Get the Plural Rule for Your Locale with the JavaScript PluralRules Constructor

The plural rules for different locales are different. It’s hard to be aware of all of them if we’re building apps that’s aware of different locales. Fortunately, there’s the Intl.PluralRules constructor in JavaScript where we can get the plural language rule for the locale you select and the type of plural rule for want to look up. We can adjust the number of digits to use in our lookup, the number of whole number or fractional digits to use when we check the number. Also, we can select how many significant digits to use when looking up the plural rule for with the Intl.PluralRules constructor.

The Intl.NumberFormat constructor takes 2 arguments, first is the locales argument, which takes one locale string or an array of locale strings. This is an optional argument. It takes a BCP 47 language tag for the locale. An abridged list of BCP 47 language tags include:

  • ar — Arabic
  • bg — Bulgarian
  • ca — Catalan
  • zh-Hans — Chinese, Han (Simplified variant)
  • cs — Czech
  • da — Danish
  • de — German
  • el — Modern Greek (1453 and later)
  • en — English
  • es — Spanish
  • fi — Finnish
  • fr — French
  • he — Hebrew
  • hu — Hungarian
  • is — Icelandic
  • it — Italian
  • ja — Japanese
  • ko — Korean
  • nl — Dutch
  • no — Norwegian
  • pl — Polish
  • pt — Portuguese
  • rm — Romansh
  • ro — Romanian
  • ru — Russian
  • hr — Croatian
  • sk — Slovak
  • sq — Albanian
  • sv — Swedish
  • th — Thai
  • tr — Turkish
  • ur — Urdu
  • id — Indonesian
  • uk — Ukrainian
  • be — Belarusian
  • sl — Slovenian
  • et — Estonian
  • lv — Latvian
  • lt — Lithuanian
  • tg — Tajik
  • fa — Persian
  • vi — Vietnamese
  • hy — Armenian
  • az — Azerbaijani
  • eu — Basque
  • hsb — Upper Sorbian
  • mk — Macedonian
  • tn — Tswana
  • xh — Xhosa
  • zu — Zulu
  • af — Afrikaans
  • ka — Georgian
  • fo — Faroese
  • hi — Hindi
  • mt — Maltese
  • se — Northern Sami
  • ga — Irish
  • ms — Malay (macrolanguage)
  • kk — Kazakh
  • ky — Kirghiz
  • sw — Swahili (macrolanguage)
  • tk — Turkmen
  • uz — Uzbek
  • tt — Tatar
  • bn — Bengali
  • pa — Panjabi
  • gu — Gujarati
  • or — Oriya
  • ta — Tamil
  • te — Telugu
  • kn — Kannada
  • ml — Malayalam
  • as — Assamese
  • mr — Marathi
  • sa — Sanskrit
  • mn — Mongolian
  • bo — Tibetan
  • cy — Welsh
  • km — Central Khmer
  • lo — Lao
  • gl — Galician
  • kok — Konkani (macrolanguage)
  • syr — Syriac
  • si — Sinhala
  • iu — Inuktitut
  • am — Amharic
  • tzm — Central Atlas Tamazight
  • ne — Nepali
  • fy — Western Frisian
  • ps — Pushto
  • fil — Filipino
  • dv — Dhivehi
  • ha — Hausa
  • yo — Yoruba
  • quz — Cusco Quechua
  • nso — Pedi
  • ba — Bashkir
  • lb — Luxembourgish
  • kl — Kalaallisut
  • ig — Igbo
  • ii — Sichuan Yi
  • arn — Mapudungun
  • moh — Mohawk
  • br — Breton
  • ug — Uighur
  • mi — Maori
  • oc — Occitan (post 1500)
  • co — Corsican
  • gsw — Swiss German
  • sah — Yakut
  • qut — Guatemala
  • rw — Kinyarwanda
  • wo — Wolof
  • prs — Dari
  • gd — Scottish Gaelic

The second argument accepts an object with a few properties — localeMatcher , type , minimumIntegerDigits, minimumFractionDigits, maximumFractionDigits, minimumSignificantDigits, and maximumSignificantDigits .

The localeMatcher option specifies the locale matching algorithm to use. The possible values are lookup and best fit . The lookup algorithm search for the locale until it finds the one that fits the character set of the strings that are being compared. best fit finds the locale that is at least but possibly more suited that the lookup algorithm.

The type option let us select the type of plural rule to use. Possible values are cardinal and ordinal . cardinal refer to the quantity of things and is the default value. ordinal refers to ordinal numbers, which are for ordering and ranking items, like 1st, 2nd, and 3rd in English.

minimumIntegerDigits, minimumFractionDigits, and maximumFractionDigits are considered one group of options. minimumIntegerDigits specifies the minimum number of integer digits to use, ranging from 1 to 21, with 1 being the default option. minimumFractionDigits is the minimum number of fraction digits to use, ranging from 0 to 20. The default is 0 for plain number and percent formatting. The default for currency formatting is specified by the ISO 4217 currency code list, and 2 if it’s not specified in the list. maximumFractionDigits is the maximum number of fraction digits to use, with possible values ranging from 0 to 20. The default for plain number is the maximum between minimumFractionDigits and 3. The default for currency formatting is the maximum between minimumFractionDigits and the number of fractional unit digits provided by the ISO 4217 currency code list or 2 if the list doesn’t provide that information. The default for percent formatting is the maximum between minimumFractionDigits and 0.

minimumSignificantDigits and maximumSignificantDigits are considered as another group of options. If at least one of the options in this group is defined, then the first group is ignored. minimumSignificantDigits is the minimum number of significant digits to use, with possible values ranging from 1 to 21 with the default being 1. maximumSignificantDigits is the maximum number of significant digits to use, with possible values ranging from 1 to 21 with the default being 21.

Methods

The constructed object has a few methods. There are the select and resolvedOptions methods. The select method returns a string with the plural rule and takes a number as an argument. The returned rule is computed according to the values we set in the constructor like the locale, the type of plural rule to get and the number of digit options that are set. The resolvedOptions method returns an object with the options we selected for computing the plural rule with the locale and collation options during the initialization of the object.

For example, we can use the select method to get the plural rule for English as follows:

const rule = new Intl.PluralRules('en', {
  type: 'ordinal'
}).select(1);
console.log(rule);

If the code above is run, we get ‘one’ logged. We can do this for other numbers like in the following code:

const rule0 = new Intl.PluralRules('en', {
  type: 'ordinal'
}).select(0);
console.log(rule0);
// other

const rule2 = new Intl.PluralRules('en', {
  type: 'ordinal'
}).select(2);
console.log(rule2);
// two

const rule3 = new Intl.PluralRules('en', {
  type: 'ordinal'
}).select(3);
console.log(rule3);
// few

const rule4 = new Intl.PluralRules('en', {
  type: 'ordinal'
}).select(4);
console.log(rule4);
// other

const rule5 = new Intl.PluralRules('en', {
  type: 'ordinal'
}).select(5);
console.log(rule5);
// other

const rule6 = new Intl.PluralRules('en', {
  type: 'ordinal'
}).select(6);
console.log(rule6);
// other

We can also use this for non-English locales, like in the following code:

const rule0 = new Intl.PluralRules('fr', {
  type: 'cardinal'
}).select(0);
console.log(rule0);
// one

const rule1 = new Intl.PluralRules('fr', {
  type: 'cardinal'
}).select(1);
console.log(rule);
// one

const rule2 = new Intl.PluralRules('fr', {
  type: 'cardinal'
}).select(2);
console.log(rule2);
// two

const rule3 = new Intl.PluralRules('fr', {
  type: 'cardinal'
}).select(3);
console.log(rule3);
// few

const rule4 = new Intl.PluralRules('fr', {
  type: 'cardinal'
}).select(4);
console.log(rule4);
// other

const rule5 = new Intl.PluralRules('fr', {
  type: 'cardinal'
}).select(5);
console.log(rule5);
// other

const rule6 = new Intl.PluralRules('fr', {
  type: 'cardinal'
}).select(6);
console.log(rule6);
// other

With the returned string, we can map it to the suffix for the corresponding the plural rule if needed so we can append the correct suffix to the given word or number.

To use the resolvedOptions method, we can write something like the following:

const options = new Intl.PluralRules('en', {
  type: 'ordinal',
  maxiumSignificantDigits: 1
}).resolvedOptions()

console.log(options);

When we run the above, we get the following from the console.log statement above:

{
  "locale": "en",
  "type": "ordinal",
  "minimumIntegerDigits": 1,
  "minimumFractionDigits": 0,
  "maximumFractionDigits": 3,
  "pluralCategories": [
    "few",
    "one",
    "two",
    "other"
  ]
}

We see all the options we set in the constructor and the pluralCategories property for the possible plural rules for the locale.

The plural rules are different for different locales. It’s hard to be aware of all of them if we’re building apps that’s aware of different locales. Fortunately, there’s the Intl.PluralRules constructor in JavaScript where we can get the plural language rule for the locale you select and the type of plural rule for want to look up. We can adjust the number of digits to use in our lookup, the number of whole number or fractional digits to use when we check the number. Also, we can select how many significant digits to use when looking up the plural rule for with the Intl.PluralRules constructor. We can use the plural rules to map to the correct suffix for words with the given quantity.

Categories
Angular

How to Use Input Masks to Validate Input in an Angular App

Input validation is always a chore to set up. An input mask is a way to enforce the format of the user’s input in a simple way. When an input mask is applied to an input element, only input in a set format can be entered.

For example, if an input has an input mask of for phone may have 3 digits for the area code, followed by a dash, then 3 digits for the prefix, followed by another dash, and then followed by the remaining 4 digits.

There are many JavaScript libraries for adding an input task to input fields. If we are writing an Angular app, we can use the ngx-mask library, located at https://www.npmjs.com/package/ngx-mask. The library allows us to enforce input format and also has a pipe to transform template variables to a designated format.

In this article, we will build a tip calculator that gets the tip rates of different countries and let users enter the amount they’ve spent before tips, and the number of people to split the after tip amount with. We will get the list of countries along with their currencies from https://restcountries.eu/rest/v2/all. To start building the app, we first install Angular CLI if not installed already by running npm i -g @angular/cli . Next, we create our project by running ng new tip-calculator . In the wizard, we choose to include routing and use SCSS as our CSS preprocessor.

Then we install some packages. We need the ngx-mask package we mentioned above as well as MobX to store the countries in a shared store. To install them, we run:

npm i ngx-mask mobx mobx-angular

Next we create our components and services. To do this, we run:

ng g component homePage
ng g service tips
ng g class countriesStore

Now we are ready to write some code. In home-page.component.html , we replace the existing code with:

<h1 class="text-center">Tip Calculator</h1>
<form (ngSubmit)="calculate(tipForm)" #tipForm="ngForm">
  <div class="form-group">
    <label>Amount</label>
    <input
      type="text"
      class="form-control"
      placeholder="Amount"
      #amount="ngModel"
      name="amount"
      [(ngModel)]="form.amount"
      required
      mask="9999990*.99"
    />
    <div *ngIf="amount?.invalid && (amount.dirty || amount.touched)">
      <div *ngIf="amount.errors.required">
        Amount is required.
      </div>
      <div *ngIf="amount.invalid">
        Amount is invalid.
      </div>
    </div>
  </div>

  <div class="form-group">
    <label>Number of People</label>
    <input
      type="text"
      class="form-control"
      placeholder="Number of People"
      #amount="ngModel"
      name="amount"
      [(ngModel)]="form.numPeople"
      required
      mask="9999990"
    />
    <div *ngIf="numPeople?.invalid && (numPeople.dirty || numPeople.touched)">
      <div *ngIf="numPeople.errors.required">
        Number of people is required.
      </div>
      <div *ngIf="numPeople.invalid">
        Number of people is invalid.
      </div>
    </div>
  </div>

  <div class="form-group">
    <label>Country</label>
    <select
      class="form-control"
      #country="ngModel"
      name="country"
      [(ngModel)]="form.country"
      required
    >
      <option *ngFor="let c of store.countries" [value]="c.name">
        {{ c.name }}
      </option>
    </select>
    <div *ngIf="country?.invalid && (country.dirty || country.touched)">
      <div *ngIf="country.errors.required">
        Country is required.
      </div>
    </div>
  </div>

  <button class="btn btn-primary">Calculate</button>
</form>

<br />

<div class="card">
  <div class="card-body">
    <h5 class="card-title">Result</h5>
    <p class="card-text">
      Amount after tip: {{ amountAfterTip | mask: "9999999.99" }} {{ currency }}
    </p>
    <p class="card-text">
      Amount after tip split between {{ this.form.numPeople }} people:
      {{ splitAmountAfterTip | mask: "9999999.99" }} {{ currency }}
    </p>
  </div>
</div>

We add the tip calculator form to let users enter their before tip amount, the number of people eating together, and the country the user is in. We use Angular’s template driven form validation to check if everything is filled in. In addition, we use the mask directive provided by ngx-mask to make sure that users enter a monetary amount into the Amount field, and we use the same directive to enforce the that the number of people is a non-negative number. The Country field has options populated by getting them from our MobX store.

At the bottom of the page, we display the results after calculation. We use the mask filter also provided by ngx-mask to display the currency amounts with the right amount of decimal places.

Next in home-page.component.ts , we replace the existing code with:

import { Component, OnInit } from '@angular/core';
import { TipsService } from '../tips.service';
import { countriesStore } from '../countries-store';
import { NgForm } from '@angular/forms';

@Component({
  selector: 'app-home-page',
  templateUrl: './home-page.component.html',
  styleUrls: ['./home-page.component.scss']
})
export class HomePageComponent implements OnInit {
  store = countriesStore;
  countries: any[] = [];
  form: any = <any>{};
  amountAfterTip: number = 0;
  splitAmountAfterTip: number = 0;
  currency: string = '';

  constructor(private tipsService: TipsService) { }

  ngOnInit() {
    if (this.store.countries.length == 0) {
      this.tipsService.getCountries()
        .subscribe(res => {
          this.store.setCountries(res);
        })
    }
  }

  calculate(tipForm: NgForm) {
    if (tipForm.invalid) {
      return;
    }
    const country = this.store.countries.find(c => c.name == this.form.country)
    this.currency = country ? country.currencies[0].code : '';
    this.amountAfterTip = +this.form.amount * (1 + this.tipsService.getTipRates(this.form.country));
    this.splitAmountAfterTip = +this.amountAfterTip / +this.form.numPeople;
  }
}

In the ngOnInit hook, we get the countries and put them in our MobX store if it’s not populated already. We also have the calculate function that we called in the template. We check that the tipForm we defined in the template is valid, and if it is, then we do the tip calculations and get the currency name by the country.

In app-routing.module.ts , we put:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomePageComponent } from './home-page/home-page.component';

const routes: Routes = [
  { path: '', component: HomePageComponent },
]

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

so users can see the pages we just added when they click on the links or enter the URLs.

Next in app.component.html , we put:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" routerLink="/">Tip Calculator</a>
  <button
    class="navbar-toggler"
    type="button"
    data-toggle="collapse"
    data-target="#navbarSupportedContent"
    aria-controls="navbarSupportedContent"
    aria-expanded="false"
    aria-label="Toggle navigation"
  >
    <span class="navbar-toggler-icon"></span>
  </button>

<div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item active">
        <a class="nav-link" routerLink="/">Home </a>
      </li>
    </ul>
  </div>
</nav>

<div class="page">
  <router-outlet></router-outlet>
</div>

to add the links to our pages and expose the router-outlet so users can see our pages.

Then in app.component.scss , we add:

.page {
  padding: 20px;
}

nav {
  background-color: lightsalmon !important;
}

to add padding to our pages and change the color of our Bootstrap navigation bar.

In app.module.ts , we replace the existing code with:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NgxMaskModule } from 'ngx-mask'
import { MobxAngularModule } from 'mobx-angular';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomePageComponent } from './home-page/home-page.component';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { TipsService } from './tips.service';

@NgModule({
  declarations: [
    AppComponent,
    HomePageComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    NgxMaskModule.forRoot(),
    MobxAngularModule,
    FormsModule,
    HttpClientModule,
  ],
  providers: [TipsService],
  bootstrap: [AppComponent]
})
export class AppModule { }

we add our components, services, and libraries that we use in our app.

Then in countriesStore.ts , we add:

import { observable, action } from 'mobx-angular';

class CountriesStore {
    @observable countries = [];
    @action setCountries(countries) {
        this.countries = countries;
    }
}

export const countriesStore = new CountriesStore();

to create the MobX store to get our components share the data. Whenever we call this.store.setCountriesin our components we set the currencies data in this store since we added the @action decorator before it. When we call this.store.countries in our component code we are always getting the latest value from this store since has the @observable decorator.

Then in tips.service.ts , we replace the existing code with:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class TipsService {

  constructor(private http: HttpClient) { }

  getCountries() {
    return this.http.get('https://restcountries.eu/rest/v2/all');
  }

  getTipRates(country) {
    const ROUND_UP_COUNTRIES = `France,Italy,Hungary,Greece,Latvia`.split(',').map(c => c.trim());
    const FIVE_PERCENT_TIP_COUNTRIES = `
      Ecuador,
      Argentina,
      Austria,
      Albania,
      Turkey,
      India,
      Slovenia,
      Romania,
      Lithuania,
      Russia
    `.split(',')
      .map(c => c.trim());
    const TEN_PERCENT_TIP_COUNTRIES = `
      Colombia,
      Slovakia,
      Estonia,
      Cuba,
      Uruguay,
      Bulgaria
    `.split(',')
      .map(c => c.trim());
    const FIFTEEN_PERCENT_TIP_COUNTRIES = `
      Serbia,
      Canada,
      Mexico,
      Chile,
      Poland,
      Ukraine,
      Egypt,
      Armenia
    `.split(',')
      .map(c => c.trim());

    const TWENTY_PERCENT_TIP_COUNTRIES = ['United States']

    if (TWENTY_PERCENT_TIP_COUNTRIES.includes(country)) {
      return 0.2;
    }

    if (FIFTEEN_PERCENT_TIP_COUNTRIES.includes(country)) {
      return 0.15;
    }

    if (TEN_PERCENT_TIP_COUNTRIES.includes(country)) {
      return 0.1;
    }

    if (FIVE_PERCENT_TIP_COUNTRIES.includes(country) || ROUND_UP_COUNTRIES.includes(country)) {
      return 0.05;
    }

    return 0

  }
}

to add the getCountries function to get the countries list from the REST Countries API, and the getTipRates to get the tip rate by country.

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>World Tip Calculator</title>
    <base href="/" />

<meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
    <link
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <script
      src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
      integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
      integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
      crossorigin="anonymous"
    ></script>
  </head>
  <body>
    <app-root></app-root>
  </body>
</html>

to add the Bootstrap CSS and JavaScript dependencies into our app, as well as changing the title.

After all the work, we can run ng serve to run the app. Then we should get:

You shouldn’t be able to enter anything other than numbers in the Amount and Number of People fields.

Categories
React Answers

How to change the dropdown icon in React Material UI select field?

Sometimes, we want to change the dropdown icon in React Material UI select field.

In this article, we’ll look at how to change the dropdown icon in React Material UI select field.

How to change the dropdown icon in React Material UI select field?

To change the dropdown icon in React Material UI select field, we can set the IconComponent prop to a function that returns the icon component we want to render.

For instance, we write:

import React from "react";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import Person from "@material-ui/icons/Person";

export default function App() {
  const [age, setAge] = React.useState("");

  const handleChange = (event) => {
    setAge(event.target.value);
  };

  return (
    <Select
      value={age}
      onChange={handleChange}
      IconComponent={() => <Person />}
    >
      <MenuItem value={10}>Ten</MenuItem>
      <MenuItem value={20}>Twenty</MenuItem>
      <MenuItem value={30}>Thirty</MenuItem>
    </Select>
  );
}

We set the Select‘s IconComponent prop to a function that returns the Person icon component.

And we add some MenuItem components to add some choices.

Now we should see the person icon as the drop down’s icon on the right.

Conclusion

To change the dropdown icon in React Material UI select field, we can set the IconComponent prop to a function that returns the icon component we want to render.

Categories
React Answers

How to change the height of the drawer with React Material UI?

Sometimes, we want to change the height of the drawer with React Material UI.

In this article, we’ll look at how to change the height of the drawer with React Material UI.

How to change the height of the drawer with React Material UI?

To change the height of the drawer with React Material UI, we can set the PaperProps prop to an object with the style property.

For instance, we write:

import React from "react";
import Drawer from "@material-ui/core/Drawer";
import MenuItem from "@material-ui/core/MenuItem";

export default function App() {
  return (
    <Drawer open PaperProps={{ style: { height: "90vh" } }}>
      <MenuItem>Menu Item</MenuItem>
      <MenuItem>Menu Item 2</MenuItem>
    </Drawer>
  );
}

We add the drawer by adding the Drawer component.

Then we set the PaperProps prop to { style: { height: "90vh" } } to set the drawer’s height to 90vh.

Finally, we add some MenuItems in the Drawer to add some content into the drawer.

Conclusion

To change the height of the drawer with React Material UI, we can set the PaperProps prop to an object with the style property.