In today’s tutorial, we’ll learn how to build a simple, yet fully functional weather app with Vanilla JavaScript. We have a lot of interesting things to cover, so grab a cup of coffee and let’s get started!
What We’ll be Building
Here’s an introductory video which demonstrates the functionality of the app that we’re going to create:
Here’s the demo on CodePen for you to fork and play with:
Note: This tutorial assumes that you’re familiar with AJAX, an essential front-end technique. If you’re just beginning, check out this series.
1. Scaffolding the App
Before start creating our app, there are a few things that we have to take into consideration.
Find a Weather API
First things first, we have to find a provider that will let us incorporate its weather data into our app. Luckily enough, there are several different providers out there for developing weather apps. Most of them include a free package along with premium subscriptions that scale depending on the services/features.
In our case, we’re going to use OpenWeatherMap, one of the most popular free choices. To take advantage of its capabilities, first, we have to sign up for an API key:
This service comes with different packages. As you can see from the visualization below, the starter (free) one allows 60 calls per minute which suits our needs:
So before continuing, please make sure that you’ve registered for an API key. Later, we’ll include that key in our script.
Keep in mind that the best way to test the app is by forking the Codepen demo and including your own key. If we all share the same key, the app will probably not work due to API call limits.
Find Weather Icons
Next, for the purposes of this tutorial, we’re going to need a bunch of weather icons. It’s worth noting that OpenWeatherMap comes with its own icon set and we’ll take a look at those. However, we’ll go one step further and use some custom ones.
Once more, we’ll take advantage of the Envato Elements library and download a pack of vector weather icons:
2. Define the Page Markup
We’ll define two sections.
The first section will include a heading, a search form, and an empty span
element. This element will become visible with an appropriate message under certain conditions. Specifically, if there isn’t any weather data available for a requested city or the data for this city are already known.
The second section will include a list of cities. By default, it won’t contain any cities. But, as we start searching for the weather for a specific city, if weather data is available, a corresponding list item (city) will be appended to the unordered list.
Here’s the initial page markup:
<section class="top-banner"> <div class="container"> <h1 class="heading">Simple Weather App</h1> <form> <input type="text" placeholder="Search for a city" autofocus> <button type="submit">SUBMIT</button> <span class="msg"></span> </form> </div> </section> <section class="ajax-section"> <div class="container"> <ul class="cities"></ul> </div> </section>
Note: In our Codepen demo, the autofocus
attribute of the search field won’t work. In fact, it’ll throw the following error which you can see if you open your browser console:
However, if you run this app locally (not as a Codepen project), this issue won’t exist.
And here’s the markup associated with a list item that we’ll generate dynamically through JavaScript:
<li class="city"> <h2 class="city-name" data-name="..."> <span>...</span> <sup>...</sup> </h2> <span class="city-temp">...<sup>°C</sup></span> <figure> <img class="city-icon" src="..." alt="..."> <figcaption>...</figcaption> </figure> </li>
2. Specify Some Basic Styles
With the markup for the app ready, we’ll forge on with the CSS. The first step, as always, is to specify some CSS variables and common reset styles:
:root { --bg_main: #0a1f44; --text_light: #fff; --text_med: #53627c; --text_dark: #1e2432; --red: #ff1e42; --darkred: #c3112d; --orange: #ff8c00; } * { margin: 0; padding: 0; box-sizing: border-box; font-weight: normal; } button { cursor: pointer; } input { -webkit-appearance: none; } button, input { border: none; background: none; outline: none; color: inherit; } img { display: block; max-width: 100%; height: auto; } ul { list-style: none; } body { font: 1rem/1.3 "Roboto", sans-serif; background: var(--bg_main); color: var(--text_dark); padding: 50px; }
4. Set the Main Styles
Let’s now discuss the main styles of our app.
Section #1 Styles
First, we’ll add some straightforward styles to the elements of the first section.
On medium screens and above (>700px) the layout should look like this:
On smaller screens the form elements will split into two lines:
Here are the associated styles:
/*CUSTOM VARIABLES HERE*/ .top-banner { color: var(--text_light); } .heading { font-weight: bold; font-size: 4rem; letter-spacing: 0.02em; padding: 0 0 30px 0; } .top-banner form { position: relative; display: flex; align-items: center; } .top-banner form input { font-size: 2rem; height: 40px; padding: 5px 5px 10px; border-bottom: 1px solid; } .top-banner form input::placeholder { color: currentColor; } .top-banner form button { font-size: 1rem; font-weight: bold; letter-spacing: 0.1em; padding: 15px 20px; margin-left: 15px; border-radius: 5px; background: var(--red); transition: background 0.3s ease-in-out; } .top-banner form button:hover { background: var(--darkred); } .top-banner form .msg { position: absolute; bottom: -40px; left: 0; max-width: 450px; min-height: 40px; } @media screen and (max-width: 700px) { .top-banner form { flex-direction: column; } .top-banner form input, .top-banner form button { width: 100%; } .top-banner form button { margin: 20px 0 0 0; } .top-banner form .msg { position: static; max-width: none; min-height: 0; margin-top: 10px; } }
Section #2 Styles
We’ll use CSS Grid to lay out the list items. Remember that each list item will represent a city. Their width will depend on the screen size.
On large screens (>1000px) we’ll have a four column layout.
Then on medium screens (>700px and ≤1000px) a three column layout, on small screens (>500px and ≤700px) a two column layout, and finally on extra small screens (≤500px) all elements will be stacked.
Here are the corresponding styles:
.ajax-section { margin: 50px 0 20px; } .ajax-section .cities { display: grid; grid-gap: 32px 20px; grid-template-columns: repeat(4, 1fr); } @media screen and (max-width: 1000px) { .ajax-section .cities { grid-template-columns: repeat(3, 1fr); } } @media screen and (max-width: 700px) { .ajax-section .cities { grid-template-columns: repeat(2, 1fr); } } @media screen and (max-width: 500px) { .ajax-section .cities { grid-template-columns: repeat(1, 1fr); } }
Each column will look like a card with a bottom shadow that will be added via the ::after
pseudo-element.
Inside the card, we’ll place weather information about the requested city. These will come from our request, apart from the icons. Those icons, which as mentioned above are grabbed from Envato Elements, will show the current weather condition of this city and match the equivalent OpenWeatherMap icons.
Below you can see a part of the CSS needed for this layout:
/*CUSTOM VARIABLES HERE*/ .ajax-section .city { position: relative; padding: 40px 10%; border-radius: 20px; background: var(--text_light); color: var(--text_med); } .ajax-section .city::after { content: ’’; width: 90%; height: 50px; position: absolute; bottom: -12px; left: 5%; z-index: -1; opacity: 0.3; border-radius: 20px; background: var(--text_light); } .ajax-section figcaption { margin-top: 10px; text-transform: uppercase; letter-spacing: 0.05em; } .ajax-section .city-temp { font-size: 5rem; font-weight: bold; margin-top: 10px; color: var(--text_dark); } .ajax-section .city sup { font-size: 0.5em; } .ajax-section .city-name sup { padding: 0.2em 0.6em; border-radius: 30px; color: var(--text_light); background: var(--orange); } .ajax-section .city-icon { margin-top: 10px; width: 100px; height: 100px; }
5. Add the JavaScript
At this point, we’re ready to build the core functionality of our app. Let’s do it!
On Form Submission
Each time a user submits the form by pressing the Enter key or the Submit button, we’ll do two things:
- Stop the form from submitting, hence prevent reloading the page.
- Grab the value which is contained in the search field.
Here’s the starting code:
const form = document.querySelector(".top-banner form"); form.addEventListener("submit", e => { e.preventDefault(); const inputVal = input.value; });
Next, we’ll check to see whether there are list items (cities) inside the second section.
Perform an AJAX Request
We’ll start with the assumption that the list is empty. That said, it has never run any AJAX request in the past. In such a case, we’ll execute a request to the OpenWeatherMap API and pass the following parameters:
- The city name (e.g. athens) or the comma-separated city name along with the country code (e.g. athens,gr) which will be the value of the search field
- The API key. Again, you should use your own key to avoid unexpected errors due to API call limits.
- The unit of temperature for the requested city. In our case, we’ll go with Celcius.
With all the above in mind, by following the API documentation, our request URL should look something like this:
const apiKey = "YOUR_OWN_KEY"; const inputVal = input.value; ... const url = `https://api.openweathermap.org/data/2.5/weather?q=${inputVal}&appid=${apiKey}&units=metric`;
To perform the AJAX request, we have a lot of options. We can use the plain old XMLHttpRequest API, the newer Fetch API, or even a JavaScript library like jQuery and Axios. For this example, we’ll go with the Fetch API.
To grab the desired data, we have to do the following things:
- Pass the URL we want to access to the
fetch()
method. - This method will return a Promise containing the response (a
Response
object). But this won’t be the actual response, just an HTTP response. To grab the response data in the desired JSON format (this is the default data format of OpenWeatherMap), we’ll use Response object’sjson()
method. - This method will return another Promise. When it’s fulfilled, the data will be available for manipulation.
- If for some reason the request is unsuccessful, a corresponding message will appear on the screen.
So, our AJAX request would look something like this:
... fetch(url) .then(response => response.json()) .then(data => { // do stuff with the data }) .catch(() => { msg.textContent = "Please search for a valid city 😩"; });
Tip: Instead of chaining then()
s, we could have used the newer and more readable async/await
approach for the AJAX request.
Here’s an example of the response data:
Build the List Item Component
With the AJAX request in place, each time we type a city in the search field, the API will return its weather data, if they are available. Our job now is to collect only the data that we need, then create the associated list item and, lastly, append it to the unordered list.
Here’s the code responsible for this job:
const { main, name, sys, weather } = data; const icon = `https://openweathermap.org/img/wn/${ weather[0]["icon"] }@2x.png`; const li = document.createElement("li"); li.classList.add("city"); const markup = ` <h2 class="city-name" data-name="${name},${sys.country}"> <span>${name}</span> <sup>${sys.country}</sup> </h2> <div class="city-temp">${Math.round(main.temp)}<sup>°C</sup> </div> <figure> <img class="city-icon" src=${icon} alt=${weather[0]["main"]}> <figcaption>${weather[0]["description"]}</figcaption> </figure> `; li.innerHTML = markup; list.appendChild(li);
There are two things here we have to discuss:
- If you look again at the response visualization above, you’ll notice that the API returns an
icon
code (e.g. "50d") which holds the current weather condition for the target city. Based on this code, we’re able to construct the icon URL and display it in the card via theimg
tag. - Inside the
.city-name
element of each list item, we’ll append thedata-name
attribute with value thecityName,countryCode
(e.g.madrid,es
). Later we’ll use this value to prevent duplicate requests.
Reset Things
Lastly, after the AJAX request, we’ll clear the content of the .msg
element, the value of the search field, and give focus to that field as well:
... msg.textContent = ""; form.reset(); input.focus();
Great job, folks! We’ve just created the first version of our app. By the time you put your own API key and search for a city, you should see a card layout similar to that one:
Here’s the related Codepen demo:
Add Custom Icons
Let’s now customize a little bit the look and feel of our app. We’ll replace the default OpenWeatherMap PNG icons with the SVGs we downloaded earlier from Envato Elements.
To do this, I’ve uploaded all the new icons to Codepen (via the Asset Manager as I’m a PRO member) and changed their names, so they will match the names and the weather conditions of the original icons, like this:
Then, in the code, we only have to change the icon path:
//BEFORE const icon = `https://openweathermap.org/img/wn/${ weather[0]["icon"] }@2x.png`; //AFTER const icon = `https://s3-us-west-2.amazonaws.com/s.cdpn.io/162656/${ weather[0]["icon"] }.svg`;
Prevent Duplicate Requests
There’s still one thing we have to fix. So far, as we perform a successful AJAX request, a list item is created. That said, the list can contain multiple identical list items which refer to the same city, like so:
That’s bad user experience, so let’s make sure that only a single request is triggered for a specific city.
But before that, there’s another thing for taking into consideration. The same city name can exist in more than one country. For example, if we search for “Athens” in the OpenWeatherMap’s search finder, we’ll see these results:
With all the above in mind, we’ll write some code which will ensure that only a single request per city, per country will be executed:
... //1 const listItems = list.querySelectorAll(".ajax-section .city"); const listItemsArray = Array.from(listItems); if (listItemsArray.length > 0) { //2 const filteredArray = listItemsArray.filter(el => { let content = ""; //athens,gr if (inputVal.includes(",")) { //athens,grrrrrr->invalid country code, so we keep only the first part of inputVal if (inputVal.split(",")[1].length > 2) { inputVal = inputVal.split(",")[0]; content = el.querySelector(".city-name span").textContent.toLowerCase(); } else { content = el.querySelector(".city-name").dataset.name.toLowerCase(); } } else { //athens content = el.querySelector(".city-name span").textContent.toLowerCase(); } return content == inputVal.toLowerCase(); }); //3 if (filteredArray.length > 0) { msg.textContent = `You already know the weather for ${ filteredArray[0].querySelector(".city-name span").textContent } ...otherwise be more specific by providing the country code as well 😉`; form.reset(); input.focus(); return; } }
Let me explain what actions happen here:
- Again during the submit handler, before making an AJAX request, we check to see whether the unordered list is empty or not. If it isn’t empty, that means at least one successful AJAX request has already been executed.
- Next, we check to see if there’s a list item who’s the city name or the value of its
data-name
attribute are equal to the search field’s value. - If so, that means the user already knows the weather for this city, so there’s no need to perform another AJAX request. As the following actions, we’ll show them a related message, clear the value of the search field and give it focus.
Note #1: As I’ve noticed, in case you search for a city with at most two-letters which don’t represent any country code (e.g. athens,aa), the API won’t return anything. On the other hand, if you search for a city along with at least three-letters which also don’t represent any country code (e.g. athens,aaaa), the API will ignore the part after the comma and return all cities named as the first part (e.g. athens).
Note #2: For this exercise, we won’t also cover the special case where a country contains more than one city with the same name (e.g. Athens in USA). So, for example, if a user searches for “athens,us” only one city will appear in the screen and not more. In order to cover the ideal scenario, users should somehow know the city ID (e.g. perhaps make them available as a dropdown) and search based on that instead of searching based on its name.
Excellent job, folks! We’ve just built our app. Let’s take a look:
Conclusion
And we’re done! This really was quite a long journey, but I hope that you enjoyed it and that it has helped enhance your front-end skills.
Once again, don’t forget to put your own key for live app testing!
As a reminder, let’s look again at how the app works:
As always, thanks a lot for reading!
Next Steps
There are so many things that you can do to extend the functionality of this app. Here are some thoughts:
- Use geolocation to grab the user’s location, and then perform an AJAX request for retrieving weather data for their closest cities.
- Use localStorage to persist the data above or even a real-time database like Firebase.
- Use a charting library like Highcharts.js for building a meteogram that will give a weather forecast. If you do so, this tutorial might help.
- Use an image API like Flickr API to present as a gallery lightbox a list of photos for each city.
If there’s anything else that you might want to see as an app extension, let me know in the comments below!
No comments:
Post a Comment