loading...
Cover image for Learn JavaScript by building a UI framework: Part 5 - Adding Events To Dom Elements

Learn JavaScript by building a UI framework: Part 5 - Adding Events To Dom Elements

carlmungazi profile image Carl Mungazi ・7 min read

This article is the fifth in a series of deep dives into JavaScript. You can view previous articles by visiting the Github repository associated with this project.

This series does not comprehensively cover every JavaScript feature. Instead, features are covered as they crop up in solutions to various problems. Also, every post is based on tutorials and open source libraries produced by other developers, so like you, I too am also learning new things with each article.


At this stage in our project we have built a basic UI framework (Aprender), test library (Examinar) and module bundler (Maleta). We have not touched our framework for a while so in this post we will return to it. The most exciting thing Aprender can do is create and render DOM elements, so what more can we make it do?

Every development tool is built to solve a particular problem and our framework is no different. Its primary purpose is to be an educational tool but for that education to be effective, it needs to happen in the context of something. That something will be a search application that allows users to choose from a selection of these free public APIs, search for something and then display the results. We will incrementally build functionality which handles this specific use case instead of worrying about our framework meeting the large number of requirements for a production level tool. For example, production standard UI libraries have to handle all the various quirks and requirements of every DOM element. Aprender will only handle the elements needed to create the application.

The first order of business is to list the user stories for our search app:

  • As a user, I can view the search app
  • As a user, I can select an API
  • As a user, after selecting an API, I can view information explaining the API and what search parameters I can use
  • As a user, I can type in the search field and click the search button
  • As a user, after clicking the search button I can view the search results
  • As a user, I can clear the search results

We will also refactor our demo app to reflect the new goal:

const aprender = require('../src/aprender');

const Button = aprender.createElement('button', { 
    attrs: {
      type: 'submit'
    },
    children: ['Search'] 
  }
);
const Search = aprender.createElement('input', { attrs: { type: 'search' }});

const Form = aprender.createElement('form', {
    attrs: { 
      id: 'form',
      onsubmit: (e) => { 
        e.preventDefault(); 
        console.log('I am being submitted..') 
      }
    },
    children: [
      Search,
      Button
    ]
  },
);

const App = aprender.render(Form);

aprender.mount(App, document.getElementById('app'));

The only new addition in the code above is the function assigned to the onsubmit property of the form's attrs object and it is this functionality we will explore next.

Events and DOM elements

Adding event handling functionality to DOM elements is straightforward. You grab a reference to an element using a method such as getElementById() and then use the addEventListener method to set up the function that is called whenever an event is triggered.

For Aprender's event handling functionality, we will take inspiration from Mithril. In our framework, the renderElement function is responsible for attaching attributes to DOM elements, so we will put the event code there:

const EventDictionary = {
  handleEvent (evt) {
    const eventHandler = this[`on${evt.type}`];
    const result = eventHandler.call(evt.currentTarget, evt);

    if (result === false) {
      evt.preventDefault();
      evt.stopPropagation();
    } 
  }
}

function renderElement({ type, attrs, children }) {
  const $el = document.createElement(type);

  for (const [attribute, value] of Object.entries(attrs)) {
    if (attribute[0] === 'o' && attribute[1] === 'n') {
      const events = Object.create(EventDictionary);
      $el.addEventListener(attribute.slice(2), events)
      events[attribute] = value;
    }

    $el.setAttribute(attribute, value);
  }
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
};

We are only interested in registering on-event handlers. Mithril and Preact both screen for these event types by checking if the first two letters of the attribute name begin with 'o' and 'n' respectively. We will do the same. addEventListener takes the event name as its first argument and either a function or object as the second argument. Typically, it is written like this:

aDomElement.addEventListener('click,' () => console.log('do something'));

Like Mithril, we will use an object but its creation will be different. Mithril's source has some comments which explain their approach and offer great insight into the considerations framework authors make when building their tools.

First, the event object is created using the new EventDict() constructor pattern as opposed to our Object.create(EventDictionary) approach. In Mithril, the object created whenever new EventDict() is called is prevented from inheriting from Object.prototype by this line:

EventDict.prototype = Object.create(null);

Mithril maintainer Isiah Meadows said one of the reasons this was done was to guard against third parties adding properties such as onsubmit or onclick to Object.prototype.

We are not worried about this so we create an object called EventDictionary which implements the EventListener interface. We then use Object.create to specify EventDictionary as the prototype and create an object which will hold a list of on-event handlers for the DOM element in question. Finally, the newly created object is assigned the attribute value.

After this, whenever an event is triggered on the DOM element in question, the handleEvent function on EventDictionary will be called and given the event object. If the event exists on the event object, it is invoked using call and we specify the DOM element as the this context and pass the event object as the only argument. If our handler's return value is false, the result === false clause will stop the browser's default behaviour and also prevent the event from propagating.

There is an excellent in-depth post which explains the differences of the Object.create approach over new Func() when creating objects. This Stack Overflow question also has some interesting thoughts on the two patterns.

A little bit about events

If we run our application, we should see an input field with a button next to it. Typing some text and clicking the button should log I am being submitted.. in our console. But if we remember, the first line in our form's onsubmit function is:

const Form = aprender.createElement('form', {
    // ...
      onsubmit: (e) => { 
        e.preventDefault(); 
        console.log('I am being submitted..') 
      }
    // ...
  },
);

What is e.preventDefault() and what does it do? The default behaviour when a form's onsubmit handler is called is for its data to be sent to the server and the page to be refreshed. Obviously, this is not always ideal. For one, you might want to validate the data before its sent or you might want to send the data via another method. The preventDefault function is a method on the Event object and it tells the browser to prevent the default action. However, if you were to programmatically create a form like this:

const form = document.createElement('form');
form.action = 'https://google.com/search';
form.method = 'GET';

form.innerHTML = '<input name="q" value="JavaScript">';

document.body.append(form);

Submitting the form by calling form.submit() would not generate the submit event and the data would be sent.

The next event we will look at is on our input field. We need to capture the input value so we can use it to make a request to the selected API. We have a few events we can choose from for this: oninput, onblur and onchange.

The onblur event fires when a focused element loses focus. In our case, it would fire only when the user focused away from the input field. The onchange event fires when the user changes the value of a form control, like our input field, and then focuses away from it. Finally, oninput is fired each time the value changes. This means every keystroke would fire the event. We will use the oninput event because it best suits our purposes. onchange and likewise onblur would be useful if we wanted to validate the input each time the search element lost focus. Note: if you were like me and did not know much about events when you first started using React, you would have been surprised to know that React's onchange event behaves exactly like oninput. There is even an issue about it.

Our final act will be to create a select element for our list of API options and attach an onchange event handler to it. And with that, our application code should look like this:

const aprender = require('../src/aprender');

const Button = aprender.createElement('button', { 
    attrs: {
      type: 'submit'
    },
    children: ['Search'] 
  }
);

const Search = aprender.createElement('input', { 
  attrs: { 
    type: 'search',
    oninput: (e) => console.log(e.target.value)
  }
});

const Form = aprender.createElement('form', {
    attrs: { 
      id: 'form',
      onsubmit: (e) => { 
        e.preventDefault(); 
        console.log('I am being submitted..')  
      }
    },
    children: [
      Search,
      Button
    ]
  },
);

const Dropdown = aprender.createElement('select', {
  attrs: {
    onchange: (e) => console.log(e.target.value)
  },
  children: [
    aprender.createElement('option', {
      children: ['--Please select an API--']
    }),
    aprender.createElement('option', {
      children: ['API 1']
    }),
    aprender.createElement('option', {
      children: ['API 2']
    })
  ]
});

const SelectAPI = aprender.createElement('div', {
  children: [
    aprender.createElement('h2', { children: ['Select API: ']}),
    Dropdown
  ]
})

const Container = aprender.createElement('div', {
  children: [
    SelectAPI,
    Form
  ]
})

const App = aprender.render(Container);

aprender.mount(App, document.getElementById('app'));

Summary

We have completed our first user story:

  • As a user, I can view the search app

In the next post we will tackle:

  • As a user, I can select an API.

This feature will expose us to the core reason why UI frameworks exist - keeping the user interface in sync with the application state.

Discussion

pic
Editor guide