How to Add Keyboard Shortcuts to Your Vue.js App
Tags: vue, webdev, javascript, tutorial
Published: 2020-01-25 03:48:58

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

**Even more articles at http://thewebdev.info/**

Keyboard shortcuts is a very convenient feature for users. It allows them to do things without many clicks, increasing productivity. Keyboard shortcuts handling can easily be added to Vue.js apps with the V-Hotkey add-on, located at [https://github.com/Dafrok/v-hotkey](https://github.com/Dafrok/v-hotkey).

In this article, we will write a fitness tracker app that lets users enter their distance walked for a given day. They can use keyboard shortcuts to open the modal to add an entry and also to delete the latest entry. To start the project, we run the Vue CLI by running:

```bash
npx @vue/cli create fitness-tracker
```

In the Vue CLI wizard, select ‘Manually select features’ and select Babel, Vuex, Vue Router, and CSS Preprocessor.

Then we install a few libraries. We will install Axios for making HTTP requests, BootstrapVue for styling, V-Hotkey for letting us define keyboard shortcuts, Moment for formatting dates and Vue-Filter-Date-Format to format dates. To install them, we run:

```bash
npm i axios bootstrap-vue v-hotkey vee-validate vue-filter-date-format moment
```

Next, we start writing the app. We first create a form for adding and editing their distance walked entries. Create a file called `FitnessForm.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="Date (YYYY-MM-DD)">
        <ValidationProvider name="date" rules="required|date" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.date"
            required
            placeholder="Date"
            name="date"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>
      <b-form-group label="Distance Walked (km)">
        <ValidationProvider
          name="distance"
          rules="required|min_value:0|max_value:9999"
          v-slot="{ errors }"
        >
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.distance"
            required
            placeholder="Distance Walked"
            name="distance"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>
      <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";
import * as moment from 'moment';
export default {
  name: "FitnessForm",
  mixins: [requestsMixin],
  props: {
    edit: Boolean,
    distance: Object
  },
  data() {
    return {
      form: {}
    };
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      const offDate = new Date(this.form.date);
      const correctedDate = new Date(
        offDate.getTime() + Math.abs(offDate.getTimezoneOffset() * 60000)
      );
const params = {
        ...this.form,
        date: correctedDate
      };
if (this.edit) {
        await this.editDistance(params);
      } else {
        await this.addDistance(params);
      }
      const { data } = await this.getDistances();
      this.$store.commit("setDistances", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  watch: {
    distance: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
        this.form.date = moment(this.form.date).format("YYYY-MM-DD");
      },
      deep: true,
      immediate: true
    }
  }
};
</script>
```

In this file, we have a form to let users enter their distance walked for each date. 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. In the `distance` field, we enforce the minimum and maximum value with the help of Vee-Validate as we wrote in the `rules`.

In the `onSubmit` function we correct the date bu adding the time zone offset to our date. We only need this because we have a date in YYYY-MM-DD format, according to Stack Overflow [https://stackoverflow.com/a/14569783/6384091](https://stackoverflow.com/a/14569783/6384091). After that, we submit the data and get the latest ones and put them in our Vuex store. Then we close the modal by emitting the `saved` event to the `Home.vue` component, which we will modify later.

We have the `watch` block to watch the `distance` prop, which we will need for editing. We format the date so that it conforms to the YYYY-MM-DD format with Moment.js if the date exist. We do this so that the edit form shows the right value for the date field. We run `JSON.stringify` then `JSON.parse` to make a deep copy of the `distance` prop so that we don’t modify the original one until it’s saved.

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: {
    getDistances() {
      return axios.get(`${APIURL}/distances`);
    },
    addDistance(data) {
      return axios.post(`${APIURL}/distances`, data);
    },
    editDistance(data) {
      return axios.put(`${APIURL}/distances/${data.id}`, data);
    },
    deleteDistance(id) {
      return axios.delete(`${APIURL}/distances/${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" v-hotkey="keymap">
    <div class="text-center">
      <h1>Fitness Tracker</h1>
      <h2>Keyboard Shortcuts:</h2>
      <p>
        <b>Ctrl + Del:</b> Delete the latest fitness tracker entry.
        <b>Ctrl + Shift + A:</b> Open the modal to add a fitness tracker entry.
      </p>
    </div>
    <b-button-toolbar class="button-toolbar">
      <b-button @click="openAddModal()" variant="primary">Add Distance Walked</b-button>
    </b-button-toolbar>
<b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th sticky-column>Date</b-th>
          <b-th>Distance Walked</b-th>
          <b-th>Edit</b-th>
          <b-th>Delete</b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="d in distances" :key="d.id">
          <b-th sticky-column>{{ new Date(d.date) | dateFormat('YYYY-MM-DD') }}</b-th>
          <b-td>{{d.distance}}</b-td>
          <b-td>
            <b-button @click="openEditModal(d)">Edit</b-button>
          </b-td>
          <b-td>
            <b-button @click="deleteOneWeight(d.id)">Delete</b-button>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>
<b-modal id="add-modal" title="Add Distance Walked" hide-footer>
      <FitnessForm @saved="closeModal()" @cancelled="closeModal()" :edit="false" />
    </b-modal>
<b-modal id="edit-modal" title="Edit Distance Walked" hide-footer>
      <FitnessForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="true"
        :distance="selectedDistance"
      />
    </b-modal>
  </div>
</template>
<script>
// @ is an alias to /src
import FitnessForm from "@/components/FitnessForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
  name: "home",
  components: {
    FitnessForm
  },
mixins: [requestsMixin],
  computed: {
    distances() {
      return this.$store.state.distances.sort(
        (a, b) => +new Date(b.date) - +new Date(a.date)
      );
    },
    keymap() {
      return {
        "ctrl+del": this.deleteLatest,
        "ctrl+shift+a": this.openAddModal
      };
    }
  },
  beforeMount() {
    this.getAllDistances();
  },
  data() {
    return {
      selectedDistance: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(Distance) {
      this.$bvModal.show("edit-modal");
      this.selectedDistance = Distance;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedDistance = {};
    },
    async deleteOneDistance(id) {
      await this.deleteDistance(id);
      this.getAllDistances();
    },
    async getAllDistances() {
      const { data } = await this.getDistances();
      this.$store.commit("setDistances", data);
    },
    deleteLatest() {
      this.deleteOneDistance(this.distances[0].id);
    }
  }
};
</script>
```

We have a table to display the entered data with a BootstrapVue table. In each row, there’s an Edit and Delete button to open the edit modal and pass that data to the `FitnessForm`, and delete the entry respectively.

When the page loads, we get all the entered data with the `getAllDistances` function called in the `beforeMount` hook. In the `getAllDistances` function, we put everything in the Vuex store. Then in here, we get the latest state of the store by putting the `this.$store.state.distances`in the `computed` block of the code. In there, we also sort the weight data by reverse chronological order.

In the `script` section, we define out shortcut key combinations with the computed `keymap` property. We pass the object into the `v-hotkey` directive to enable the keyboard shortcuts. The keys of the `keymap` property has the keyboard combinations, and the value has the functions to run. So if we press Ctrl and Delete together, we run `this.deleteLatest` to delete the latest entry. If we press Ctrl, Shift and A together we call `this.openAddModal` to open the modal to add a new entry.

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="/">Fitness Tracker</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:

```javascript
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 VueFilterDateFormat from "vue-filter-date-format";
import VueHotkey from "v-hotkey";
Vue.use(VueHotkey);
Vue.use(VueFilterDateFormat);
extend("required", required);
extend("min_value", min_value);
extend("max_value", max_value);
extend("date", {
  validate: value =>
    /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/.test(value),
  message: "Date must be in YYYY-MM-DD format"
});
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 V-Hotkey library, and the Vue-Filter-Date-Format library are adding here for use in our app. The `min_value` and `max_value` rules are added for validating the weight, and we made a `date` rule for validating that the date is in YYYY-MM-DD format.

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: {
    distances: []
  },
  mutations: {
    setDistances(state, payload) {
      state.distances = payload;
    }
  },
  actions: {}
});
```

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

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>Fitness Tracker</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-hotkey-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:

```
{  
  "distances": \[  
  \]  
}
```

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