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
- Add the provided CSS to the
bus-tracker
template. - Add the
#selected-vehicle
element to the bus-tracker’sfooter
. - Add an event listener for when a marker is selected which dispatches an event called
vehicle-selected
. - Add an event listener in
bus-tracker
for thevehicle-selected
event on thegoogle-map-view
. - 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 anid
ofselected-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
anddisconnectedCallback
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>