Dispatching events page

Learn how to use custom events to provide information on the state of your component to parent elements.

Overview

In this part we will:

  • Handle click events on the map markers.
  • Display the bus destination for selected markers.

Problem

We want to display the bus destination for the selected marker. This should look similar to how the selected route is displayed as an overlay of the map, but float just below the route overlay. When no routes or markers are selected it should be invisible; when it is clicked it should say Vehicle: DESTINATION, where destination can be gotten from the vehicle object that is used to construct the marker.

How to Solve This Problem

  1. Add the provided CSS to the bus-tracker template.
  2. Add the #selected-vehicle element to the bus-tracker’s footer.
  3. Add an event listener for when a marker is selected which dispatches an event called vehicle-selected.
  4. Add an event listener in bus-tracker for the vehicle-selected event on the google-map-view.
  5. In the event listener set the vehicle.des in the #selected-vehicle element.

Technical requirements

  • For displaying the selected vehicle use a span element and give it an id of selected-vehicle. It will have mostly the same styles as the selected route. You could use the same CSS but just modify it to work for both. For simplicity here’s the full CSS needed:

    #selected-vehicle:not(.vehicle-selected) {
      display: none;
    }
    .vehicle-selected {
      line-height: 1;
      position: absolute;
      z-index: 1;
      text-align: right;
      background: rgba(6, 6, 6, 0.6);
      top: 55px;
      font-size: 11px;
      right: 10px;
      padding: 6px 10px;
      color: white;
      border-radius: 2px;
      cursor: pointer;
    }
    .vehicle-selected small {
      display: block;
      font-size: 14px;
      color: #ddd;
    }
    
  • Markers are not DOM elements and therefore don’t have all of the same methods. To listen to events you use a slightly different mechanism:

    marker.addListener('event-name', () => {
      // Do whatever
    });
    

    Where event-name is the event you’re interested in.

  • Use this snippet for setting the the destination of the bus:

    <small>Vehicle:</small> VEHICLE_DESTINATION
    

What you need to know

  • Using CustomEvent to create new event types.
  • Using the detail property to pass information about an event.
  • How to use handleEvent to listen to events in an easy way.
  • Using connectedCallback and disconnectedCallback together.

Custom events

Custom elements can have events associated with them just like any other element. You can listen to events the same way as well; in our case we’ll use addEventListener.

el.addEventListener('any-event', () => {
  // Handle this event just like you would any built-in.
});

To dispatch a custom event that contains data pertaining to the event, use the CustomEvent constructor. With CustomEvent you can include a detail property. This can contain any sort of data about the event you want to provide. In our case we’ll use the route object as the detail property.

Events are created using let ev = new CustomEvent({ detail: {...} }). This creates an event object. To dispatch it, the element (in our case a custom element) has a dispatchEvent(ev) function that takes the new event.

let el = document.createElement('div');

el.addEventListener('favorite-food', ev => {
  console.log(ev.detail); // -> "pizza"
});

let ev = new CustomEvent('favorite-food', {
  detail: 'pizza'
});
el.dispatchEvent(ev);

Events created this way do not bubble by default. This means that if you want to listen to events you must place the listener directly on the element from which the event is dispatched. You can enable bubbling by providing { bubbles: true } when creating the event.

let ev = new CustomEvent('my-event', {
  bubbles: true
});

Setting up and tearing down listeners

Most of the time you want to delay setting up event listeners until connectedCallback is called. This is for a few different reasons:

  • Most events deal with user interaction and the user can only interact with elements in the page.
  • Events can reference elements not contained within the element; for example an event might be placed on the document. This creates a memory leak where the element’s memory can never be freed.

Use connectedCallback to add event listeners and disconnectedCallback to remove them. disconnectedCallback is called when the element is removed from the page.

class MyElement extends HTMLElement {
  constructor() {
    super();

    this.attachShadow({ mode: 'open' });
    this.shadowRoot.append(document.import(template.content), true);

    this.onButtonClick = () => {
      // Do something when buttons are clicked.
    };
  }

  connectedCallback() {
    this.button = this.shadowRoot.querySelector('button');
    this.button.addEventListener('click', this.onButtonClick);
  }

  disconnectedCallback() {
    this.button.removeEventListener('click', this.onButtonClick);
  }
}

handleEvent

Most people are familiar with using addEventListener with a function like so:

element.addEventListener('click', callback);

In a custom element class you’ll often want to call some method on your class when an event occurs, but that might not do what you expect:

class MyElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.append(document.import(template.content), true);

    this.button = this.shadowRoot.querySelector('button');
  }

  connectedCallback() {
    this.button.addEventListener('click', this.handleButtonClick);
  }

  handleButtonClick() {
    // Oh no! `this` is not what you expected.
  }
}

In the above example we are listening for when a button gets a click event and calling this.handleButtonClick. However this doesn’t do what you expect; the this value when handleButtonClick is called will be the button, not your element.

You can fix this by using an arrow function instead (as shown in the previous section):

this.button.addEventListener('click', ev => this.handleButtonClick(ev));

However this creates a new function. To tear this down in disconnectedCallback you’ll have to keep a reference to it.

A way to avoid this is to use handleEvent. handleEvent is a special method you can add to any object (not just elements) that makes that object an EventTarget. You use it like this:

let eventTarget = {
  handleEvent(ev) {
    // My `this` is correct!
  }
};

window.addEventListener('scroll', eventTarget);

Instead of passing a function to addEventListener you pass an object with a handleEvent method. This can work for custom elements too. Our previous example becomes:

class MyElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.append(document.import(template.content), true);

    this.button = this.shadowRoot.querySelector('button');
  }

  connectedCallback() {
    this.button.addEventListener('click', this);
  }

  disconnectedCallback() {
    this.button.addEventListener('click', this);
  }

  handleEvent(ev) {
    // Our `this` is correct and we can do whatever we need.
  }
}

handleEvent is convenient when you only have a few events you’re curious about, but when you have many events it can be difficult to differentiate between the events since they are all on the same method.

Solution

✏️ Add <span id="selected-vehicle"></span> inside of the footer element below the button. In the google-map-view when creating markers add an event listeners to the markers click.

Using CustomEvent, display a vehicle-selected event with a detail of the vehicle. Now in the bus-tracker component, listen to event and set the vehicle destination on the #selected-vehicle element.

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;
  }
  bus-tracker {
    --header-background: salmon;
    --header-text-transform: capitalize;
  }
  header[slot=header] {
    background: crimson;
    color: wheat;
    text-align: center;
  }
</style>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyD7POAQA-i16Vws48h4yRFVGBZzIExOAJI"></script>

<bus-tracker>
  <header slot="header">
    <h1>My Bus Tracker!</h1>
  </header>
</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: var(--header-background, #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: var(--header-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;
    }
    #selected-vehicle:not(.vehicle-selected) {
      display: none;
    }
    .vehicle-selected {
      line-height: 1;
      position: absolute;
      z-index: 1;
      text-align: right;
      background: rgba(6, 6, 6, 0.6);
      top: 55px;
      font-size: 11px;
      right: 10px;
      padding: 6px 10px;
      color: white;
      border-radius: 2px;
      cursor: pointer;
    }
    .vehicle-selected small {
      display: block;
      font-size: 14px;
      color: #ddd;
    }
    .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">
    <slot name="header">
      <header>
        <h1>Chicago CTA Bus Tracker</h1>
        <p id="loading-routes">Loading routes…</p>
      </header>
    </slot>

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

    <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);

    this.markers = null;
    this._vehicles = null;
  }

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

  get vehicles() {
    return this._vehicles;
  }

  set vehicles(newVehicles) {
    this._vehicles = newVehicles;
    if (this.markers) {
      for(let marker of this.markers) {
        marker.setMap(null);
      }
      this.markers = null;
    }
    if (newVehicles) {
      this.markers = newVehicles.map(vehicle => {
        let marker = new google.maps.Marker({
          position: {
            lat: parseFloat(vehicle.lat),
            lng: parseFloat(vehicle.lon)
          },
          map: this.map
        });

        marker.addListener('click', () => {
          this.dispatchVehicle(vehicle);
        });

        return marker;
      });
    }
  }

  dispatchVehicle(vehicle) {
    let event = new CustomEvent('vehicle-selected', {
      detail: vehicle
    });
    this.dispatchEvent(event);
  }
}

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');
    this.googleMapView = this.shadowRoot.querySelector('google-map-view');
    this.selectedVehicle = this.shadowRoot.querySelector('#selected-vehicle');
  }

  connectedCallback() {
    this.getRoutes();

    this.googleMapView.addEventListener('vehicle-selected', this);
  }

  disconnectedCallback() {
    this.googleMapView.removeEventListener('vehicle-selected', this);
  }

  handleEvent(ev) {
    let vehicle = ev.detail;
    this.selectedVehicle.innerHTML = `<small>Vehicle:</small> ${vehicle.des}`;
    this.selectedVehicle.classList.add('vehicle-selected');
  }

  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);
      this.googleMapView.vehicles = [];
    } else {
      let vehicles = data['bustime-response'].vehicle;
      this.googleMapView.vehicles = 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>