To make an app’s user experience better, often you have to do something when an input element is focused. For example, you might want to highlight the label for the input when the input is focused so that users know which field they’re filling in. With Vue.js, the easiest way is the use the Vue-Focus library to do this. It provides a directive and mixin that lets you handle focused and blur events and bind it to a data field of a component.
In this article, we will make a website bookmark manager app that lets users bookmark their favorite URLs. The labels will be highlighted when the input is in focus. To start building the project, we run the Vue CLI by running:
npx @vue/cli create bookmark-app
When the wizard runs, we select ‘Manually select features’, and select Babel, CSS preprocessor, Vuex, and Vue Router.
Next we install some packages. We need Axios to make HTTP requests to our back end, Bootstrap-Vue for styling, Vee-Validate for form validation, and Vue-Focus for handling the focus state of the inputs. To install the packages, we run npm i axios bootstrap-vue vee-validate vue-focus
. After installing the packages we can start building our bookmark app.
First, we create our form for letting users add and edit their bills. In the components
folder, create a file called BookmarkForm.vue
and add:
<template>
<ValidationObserver ref="observer" v-slot="{ invalid }">
<b-form @submit.prevent="onSubmit" novalidate>
<b-form-group>
<ValidationProvider name="name" rules="required" v-slot="{ errors }">
<label :class="{'highlight': nameFocused}">Name</label>
<b-form-input
type="text"
v-model="form.name"
placeholder="Name"
name="name"
[@focus](http://twitter.com/focus "Twitter profile for @focus")="nameFocused = true"
[@blur](http://twitter.com/blur "Twitter profile for @blur")="nameFocused = false"
v-focus="nameFocused"
:state="errors.length == 0"
></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>
<ValidationProvider name="url" rules="required|url" v-slot="{ errors }">
<label :class="{'highlight': urlFocused}">URL</label>
<b-form-input
type="text"
:state="errors.length == 0"
v-model="form.url"
required
placeholder="URL"
name="url"
[@focus](http://twitter.com/focus "Twitter profile for @focus")="urlFocused = true"
[@blur](http://twitter.com/blur "Twitter profile for @blur")="urlFocused = false"
v-focus="urlFocused"
></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 { focus } from "vue-focus";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "BookmarkForm",
mixins: [requestsMixin],
directives: { focus },
props: {
bookmark: Object,
edit: Boolean
},
methods: {
async onSubmit() {
const isValid = await this.$refs.observer.validate();
if (!isValid) {
return;
}
if (this.edit) {
await this.editBookmark(this.form);
} else {
await this.addBookmark(this.form);
}
const { data } = await this.getBookmarks();
this.$store.commit("setBookmarks", data);
this.$emit("saved");
},
cancel() {
this.$emit("cancelled");
}
},
data() {
return {
form: {},
nameFocused: false,
urlFocused: false
};
},
watch: {
bookmark: {
handler(val) {
this.form = JSON.parse(JSON.stringify(val || {}));
},
deep: true,
immediate: true
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.highlight {
color: #42b983;
}
</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 name
and url
fields required in the rules
prop so that users will have to enter all of them to save the bill. Also, we made a custom url
Vee-Validate validation rule checks if the URL is valid.
In the inputs, we used the v-focus
directive provided by Vue-Focus to set the focus state of the inputs. We bind the focus state of the inputs to the nameFocused
and urlFocused
variables respectively. Once users put the cursor in the input, the label for the input will be highlighted since we set the highlight
class to the label depending on the state of the focus of the input.
We validate the values in the onSubmit
function by running this.$refs.observer.validate()
. If that resolves to true
, then we run the code to save the data by calling the functions in the if
block, then we call getNotes
to get the notes. These functions are from the requestsMixin
that we will add. The obtained data are stored in our Vuex store by calling this.$store.commit
.
In this component, we also have a watch
block to watch the bill
value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the bill
value is updated so that the latest can be edited by the user as we copy the values to this.form
.
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: {
getBookmarks() {
return axios.get(`${APIURL}/bookmarks`);
},
addBookmark(data) {
return axios.post(`${APIURL}/bookmarks`, data);
},
editBookmark(data) {
return axios.put(`${APIURL}/bookmarks/${data.id}`, data);
},
deleteBookmark(id) {
return axios.delete(`${APIURL}/bookmarks/${id}`);
}
}
};
These are the functions we use in our components to make HTTP requests to our back end to save the bookmarks.
Next in Home.vue
, replace the existing code with:
<template>
<div class="page">
<h1 class="text-center">Bookmark App</h1>
<b-button-toolbar>
<b-button @click="openAddModal()">Add Bookmark</b-button>
</b-button-toolbar>
<br />
<b-table-simple responsive>
<b-thead>
<b-tr>
<b-th>Name</b-th>
<b-th>Link</b-th>
<b-th></b-th>
<b-th></b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr v-for="b in bookmarks" :key="b.id">
<b-td>{{b.name}}</b-td>
<b-td>
<a :href="b.url">Link</a>
</b-td>
<b-td>
<b-button @click="openEditModal(b)">Edit</b-button>
</b-td>
<b-td>
<b-button @click="deleteOnebookmark(b.id)">Delete</b-button>
</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
<b-modal id="add-modal" title="Add Bookmark" hide-footer>
<BookmarkForm @saved"closeModal()" @cancelled="closeModal()" :edit="false"></BookmarkForm>
</b-modal>
<b-modal id="edit-modal" title="Edit Bookmark" hide-footer>
<BookmarkForm
@saved"closeModal()"
@cancelled="closeModal()"
:edit="true"
:bookmark="selectedBookmark"
></BookmarkForm>
</b-modal>
</div>
</template>
<script>
// @ is an alias to /src
import BookmarkForm from "@/components/BookmarkForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "home",
components: {
BookmarkForm
},
mixins: [requestsMixin],
computed: {
bookmarks() {
return this.$store.state.bookmarks;
}
},
beforeMount() {
this.getAllBookmarks();
},
data() {
return {
selectedBookmark: {}
};
},
methods: {
openAddModal() {
this.$bvModal.show("add-modal");
},
openEditModal(bookmark) {
this.$bvModal.show("edit-modal");
this.selectedBookmark = bookmark;
},
closeModal() {
this.$bvModal.hide("add-modal");
this.$bvModal.hide("edit-modal");
this.selectedBookmark = {};
this.getAllBookmarks();
},
async deleteOnebookmark(id) {
await this.deleteBookmark(id);
this.getAllBookmarks();
},
async getAllBookmarks () {
const { data } = await this.getBookmarks();
this.$store.commit("setBookmarks", data);
}
}
};
</script>
This is where we display the bills in a BootstrapVue table. The columns are the name, the amount, and the due date, along with the Edit button to open the edit modal, and Delete button to delete an entry when it’s clicked.
We also added an ‘Add Bill’ button to open the modal to let users add a bill. The notes are obtained from the back end by running the this.getAllBookmarks
function in the beforeMount
hook which stores the data in our Vuex store.
The openAddModal
, openEditModal
, closeModal
open the open and close modals, and close the modal respectively. When openEditModal
is called, we set the this.selectedBookmark
variable so that we can pass it to our BookmarkForm
.
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="/">Bookmark 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 { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
extend("url", {
validate: value => {
return /(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^s]{2,}|www.[a-zA-Z0-9]+.[^s]{2,})/.test(
value
);
},
message: "Invalid URL."
});
Vue.use(BootstrapVue);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
extend("required", required);
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 and Vee-Validate components along with the required
validation rule and a url
rule for validating that the URL entered is valid by checking against the given regex.
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: {
bookmarks: []
},
mutations: {
setBookmarks(state, payload) {
state.bookmarks = payload;
}
},
actions: {}
});
to add our bookmarks
state to the store so we can observer it in the computed
block of BookmarkForm
and HomePage
components. We have the setBookmarks
function to update the notes
state and we use it in the components by call this.$store.commit(“setBookmarks”, data);
like we did in BookmarkForm
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>Bookmark App</title>
</head>
<body>
<noscript>
<strong
>We're sorry but vue-focus-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:
{
"bookmarks": []
}
So we have the bookmarks
endpoints defined in the requests.js
available.
After all the hard work, we get: