Selecting a route page

Learn how to use event listeners with a custom element to handle user interaction and update the DOM.

Overview

In this part we will:

  • Handle selecting of routes.
  • Fetch a list of vehicles for a selected route.
  • Show the selected route name and number overlaying the map.

Problem

We have a list of routes and a map. We want to handle events when the button for a given route is clicked. It should fetch the vehicles for that route and log those to the console. In the next section we will use the vehicle list to display markers.

Additionally the route name and number should be shown overlaying the map. There is a button where this information can be added.

When a route is selected it should become active and show a checkmark by the name.

Selecting a route from the list and noticing that the item is highlighted as active and the route number and name is shown in an overlay on top of the map.

How to Solve This Problem

  1. Add a click event listener on the button within each route li.
  2. Create a method that will be called within the listener that receives the route object and the li.
  3. Create another method, this time an async method, that fetches the vehicles for the given route.
    1. Add the route name and number to the #selected-route button.
    2. If there is an error also append the error message snippet to that same button.
    3. console.log the list of vehicles. We will use them in the next section.
    4. Add the route-selected class to the #selected-route button once you have the vehicles.
  4. Add the active class to the route’s li. If there is already an active route, remove the active class from that li.

Technical requirements

Selected route button

This HTML can be used to display the route name and number. It should be a child of the #selected-route button.

<small>Route NUMBER:</small> NAME

Inspect the route object to see what the values for NUMBER and NAME should be.

Some routes may contain errors. When this happens use this markup in addition to showing the route number and name as described above:

<div class="error-message">
  No vehicles available for this route
</div>

To make the #selected-route button visible, add the route-selected class.

Calling the API

To get the URL for this API request use:

getVehiclesEndpoint + '?rt=' + NUMBER

Where NUMBER is the route number, which again can be gotten from the route object.

Marking the route active

Set the active class on the li element associated with the route to mark it as active.

What you need to know

This part doesn’t have any new web component APIs to learn, but builds off what we’ve already done. You will need to know a few basic DOM APIs however:

  • How to add event listeners to elements.
  • How to add classes to elements.

Event listeners

addEventListener is a method on elements that takes an event name, and a callback function. When the event occurs the callback function will be called.

Each route in our list contains a button element. Set the event listener on this element.

element.addEventListener('click', ev => {
  // Do whatever you need.
});

Adding classes

Each element contains a classList object which is a little like a JavaScript Set (but is subtley different). To add classes to an element use classList.add().

element.classList.add('some-new-class');

Solution

✏️ In the getRoutes method inside of the routes loop, get a reference to the button for the route and add an event listener for click events. When that event occurs call a method that will call the API for vehicles and add the active class to the associated li.

Add a reference to the #selected-route element in the constructor. When the API call completes update this element as needed and add the route-selected class so that it will be highlighted.

Click to see the solution

<style>
  html,
  body {
    height: 100%;
  }
  body {
    font-family: "Catamaran", sans-serif;
    background-color: #f2f2f2;
    display: flex;
    flex-direction: column;
    margin: 0;
  }
</style>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyD7POAQA-i16Vws48h4yRFVGBZzIExOAJI"></script>

<bus-tracker></bus-tracker>

<template id="bt-template">
  <style>
    :host {
      display: flex;
      flex-direction: column;
    }

    .top {
      flex-grow: 1;
      overflow-y: auto;
      height: 10%;
      display: flex;
      flex-direction: column;
    }

    footer {
      height: 250px;
      position: relative;
    }
    .gmap {
      width: 100%;
      height: 250px;
      background-color: grey;
    }

    header {
      box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.1);
      background-color: #313131;
      color: white;
      min-height: 60px;
      display: flex;
      flex-direction: column;
      justify-content: center;
      line-height: 1.2;
    }

    header h1 {
      text-align: center;
      font-size: 18px;
      text-transform: uppercase;
      letter-spacing: 1px;
      margin: 0;
    }
    #selected-route:not(.route-selected) {
      display: none;
    }
    .route-selected {
      line-height: 1;
      position: absolute;
      z-index: 1;
      text-align: right;
      background: rgba(6, 6, 6, 0.6);
      top: 10px;
      right: 10px;
      padding: 6px 10px;
      color: white;
      border-radius: 2px;
      cursor: pointer;
    }
    .route-selected small {
      display: block;
      font-size: 14px;
      color: #ddd;
    }
    .route-selected .error-message {
      font-size: 14px;
      background-color: #ff5722;
      border-radius: 10px;
      padding: 4px 8px 1px;
      margin-top: 5px;
    }
    .routes-list {
      padding: 20px 0;
      margin: 0;
      overflow-y: auto;
    }
    .routes-list li {
      list-style: none;
      cursor: pointer;
      background: white;
      border: 1px solid #dedede;
      margin: 1% 2%;
      border-radius: 25px;
      color: #2196f3;
      width: 41%;
      display: inline-flex;
      font-size: 14px;
      line-height: 1.2;
    }
    .routes-list li:hover {
      border-color: transparent;
      background-color: #008eff;
      color: white;
      box-shadow: 0px 5px 20px 0px rgba(0, 0, 0, 0.2);
    }
    .routes-list li .check {
      display: none;
    }
    .routes-list li.active {
      color: #666;
      background-color: #e8e8e8;
    }
    .routes-list li.active .check {
      display: inline-block;
      margin-left: 5px;
      color: #2cc532;
    }
    .routes-list li.active:hover {
      border-color: #dedede;
      box-shadow: none;
    }
    .routes-list button {
      width: 100%;
      padding: 8px 8px 6px;
      border: none;
      border-radius: 25px;
      background: transparent;
      text-align: left;
      font: inherit;
      color: inherit;
    }
    .route-number {
      display: inline-block;
      border-right: 1px solid #dedede;
      padding-right: 5px;
      margin-right: 5px;
      min-width: 18px;
      text-align: right;
    }
    p {
      text-align: center;
      margin: 0;
      color: #ccc;
      font-size: 14px;
    }
  </style>
  <div class="top">
    <header>
      <h1>Chicago CTA Bus Tracker</h1>
      <p id="loading-routes">Loading routes…</p>
    </header>

    <ul class="routes-list"></ul>
  </div>
  <footer>
    <button id="selected-route" type="button">
    </button>

    <google-map-view></google-map-view>
  </footer>
</template>
<template id="error-template">
  <div class="error-message">
    No vehicles available for this route
  </div>
</template>
<template id="gmap-template">
  <style>
    .gmap {
      width: 100%;
      height: 250px;
      background-color: grey;
    }
  </style>
  <div class="gmap"></div>
</template>
<template id="route-template">
  <li>
    <button type="button">
      <span class="route-number"></span>
      <span class="route-name"></span>
      <span class="check">✔</span>
    </button>
  </li>
</template>
<script type="module">
const template = document.querySelector('#gmap-template');

class GoogleMapView extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    let nodes = document.importNode(template.content, true);
    this.shadowRoot.append(nodes);
  }

  connectedCallback() {
    let gmap = this.shadowRoot.querySelector('.gmap');
    this.map = new google.maps.Map(gmap, {
      zoom: 10,
      center: {
        lat: 41.881,
        lng: -87.623
      }
    });
  }
}

customElements.define('google-map-view', GoogleMapView);

const apiRoot = "https://cta-bustracker.vercel.app/api/";
const getRoutesEndpoint = apiRoot + "routes";
const getVehiclesEndpoint = apiRoot + "vehicles";

const btTemplate = document.querySelector('#bt-template');
const routeTemplate = document.querySelector('#route-template');
const errorTemplate = document.querySelector('#error-template');

class BusTracker extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });

    let frag = document.importNode(btTemplate.content, true);
    this.shadowRoot.append(frag);

    this.routesList = this.shadowRoot.querySelector('.routes-list');
    this.selectedRouteBtn = this.shadowRoot.querySelector('#selected-route');
  }

  connectedCallback() {
    this.getRoutes();
  }

  async getRoutes() {
    let response = await fetch(getRoutesEndpoint);
    let data = await response.json();
    let routes = data["bustime-response"].routes;

    for(let route of routes) {
      let frag = document.importNode(routeTemplate.content, true);

      frag.querySelector('.route-number').textContent = route.rt;
      frag.querySelector('.route-name').textContent = route.rtnm;

      frag.querySelector('button').addEventListener('click', ev => {
        this.pickRoute(route, ev.currentTarget.parentNode);
      });

      this.routesList.append(frag);
    }

    this.shadowRoot.querySelector('#loading-routes').remove();
  }

  async getVehicles(route) {
    let response = await fetch(getVehiclesEndpoint + '?rt=' + route.rt);
    let data = await response.json();

    this.selectedRouteBtn.innerHTML = `
      <small>Route ${this.route.rt}:</small> ${this.route.rtnm}
    `;

    if (data['bustime-response'].error) {
      let frag = document.importNode(errorTemplate.content, true);
      this.selectedRouteBtn.append(frag);
    } else {
      let vehicles = data['bustime-response'].vehicle;
      console.log(vehicles);
    }

    this.selectedRouteBtn.classList.add('route-selected');
  }

  pickRoute(route, li) {
    this.route = route;
    this.getVehicles(route);

    if(this.activeRoute) {
      this.activeRoute.classList.remove('active');
    }
    this.activeRoute = li;
    this.activeRoute.classList.add('active');
  }
}

customElements.define("bus-tracker", BusTracker);
</script>