Chapter 3. Making your component reusable

This chapter covers

  • Using getters and setters to work with data in your component
  • Using attributeChangedCallback to listen for attribute changes
  • Identifying which attributes to listen for changes on using observedAttributes
  • Working with attributes using hasAttribute() getAttribute(), and setAttribute()

In the last chapter, we talked in great depth about simple ways to create your first Web Component. Specifically, we looked at creating your own custom element and assigning some minimal custom logic so your component acts a certain way. But what if you want your component to act differently depending on what parameters you use to set it up? What if you want your component to be adaptable? Usually, the goal in any platform, language, or framework is to create reusable code that can be simply configured to match the widest range of use cases.

Of course, saying we want to create reusable and configurable Web Components is one thing. It’s almost meaningless unless we can talk about a concrete example!

3.1. A real-world component

One of my recent interests is 3D on the web. I’m especially interested in how virtual reality (VR) and augmented reality (AR) are making their way into browsers. Delving into WebGL and Three.js or Babylon is a bit too much to get into here (and off-subject), but we can do something simple to demonstrate reusable and configurable components.

3.1.1. A 3D search use case

3D has a bit of a content problem. I love experimenting with the 3D web, but I’m definitely not an expert in creating assets with complex 3D software. My favorite thing in VR lately is the explosion of 3D painting and modeling tools. Notably, Google has been doing some awesome things with Blocks and TiltBrush, its VR tools for modeling and painting in 3D. Even better, Google has created a hub that creators can publish to called Poly.

When you go to poly.google.com, you can browse around, search for 3D models, and pick your favorite to use in your application (many are free to use and modify). What’s great for our purposes is that Poly has a REST-based API that we can tap into and use to make a 3D search Web Component of our own! Again, going all in on 3D is a little much, especially for a Web Components book—but the results we get back are all image thumbnails, so we don’t have to get complicated at all in order to search and browse.

As with many services like Poly, we’ll need to get an API key for access. If you’d rather not do this, you’re still welcome to follow along, as I’ll provide a JSON file you can use in its place, and you can run the example from your own server.

First things first. Head over to https://developers.google.com/poly/develop/web and follow the instructions for the API key. Once you have it, put it in a safe place for later.

3.1.2. Starting with an HTTP request

Let’s now test the service and create an HTTP request in the following listing (in which we search for a parrot).

Listing 3.1. Creating an HTTP request to Google’s Poly service
const url =
      'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key=
             <your_api_key>';                          1
const request = new XMLHttpRequest();                  2
request.open( 'GET', url, true );
request.addEventListener( 'load', (event) => {
   console.log(JSON.parse(                             3
     event.target.response
   ));
});
request.send();

  • 1 The Poly search API (insert your own API key)
  • 2 Creates a new HTTP request
  • 3 Callback where we log the API response

When running this, you should see all of the results that come back right in your dev tools console. It will also be nicely formatted, given that we turned the raw text of the response back into JSON, as it was intended to be: JSON.parse(event.target.response).

When we look at the console.log output, we’ll see a JSON object returned from the service. Of course, over time, these results will change, but I do see a lot of parrots in the results! Exactly what we specified in the keyword search. If we expand the assets object and look at the array of 3D assets returned in figure 3.1, we see that each asset has a thumbnail object, which we can expand to look at the thumbnail URL. This URL is what we’re after!

Figure 3.1. Our HTTP response from Google Poly featuring assets and asset details

There’s certainly lots of other data that you could use, especially if you opened up the “formats” array to reveal actual 3D object links. For our purposes, we’re just going to use and display those thumbnails.

3.1.3. Wrapping up our work in a custom component

Let’s wrap the HTTP request we just made into a new Web Component that allows us to search for assets by keyword and display the results. We should keep it simple, though. There’s no need to overburden each Web Component to do too much—I like to think that we can be extremely granular with every component, and for bigger pieces of functionality, we can combine two or more components. This is why we’re going to keep the keyword/search input out of the component. Our Web Component will only display search results based on data we pass it from the input.

To make our HTTP request snippet into a Web Component, we can use what we’ve already learned about custom elements and the connectedCallback method of the Web Components API.

Listing 3.2. Creating a Web Component from our HTTP request
<html>
<head>
   <meta charset="UTF-8">
   <title>Google Poly Search</title>
   <script>
       class PolySearch extends HTMLElement {
           connectedCallback() {
               this.doSearch();                       1
           }

           doSearch() {
               const url =
     'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key=
            <your_api_key>';
               const request = new XMLHttpRequest();
               request.open( 'GET', url, true );
               request.addEventListener( 'load', (event) => {
                   console.log(JSON.parse( event.target.response ));
               });
               request.send();                        2
           }
       }

       customElements.define(
'poly-search', PolySearch);                           3
   </script>
</head>

<body>
<poly-search></poly-search>                           4
</body>
</html>

  • 1 Calls the search function when component is added
  • 2 HTTP request from last example
  • 3 Defines our Poly search component
  • 4 Uses the Poly search element on the page

Hopefully, there’s nothing earth-shattering in this listing. I did separate out the actual HTTP request into a doSearch() method. For now, I call it on connectedCallback when the component is added to the DOM. Because I don’t have a big project that involves many components in this one example, I chose a simple element name that reflects the task I’m doing: poly-search. If I were doing multiple components for a large app, maybe I’d name it something like <myappname-poly-search>.

You might notice that our component only searches for parrots right now. I agree, this isn’t incredibly useful. First, however, let’s display our results. Figure 3.2 shows our component reaching out to the Google Poly API and returning an asset list, which our component then renders.

Figure 3.2. Our custom poly-search Web Component calling out to the Google Poly API with an API key and the search term “parrot.” We’ll get back a list of assets and thumbnails to display.

3.1.4. Rendering search results

We can start by swapping our console.log(JSON.parse(event.target.response)); with a call to another method that accepts all of the assets we requested:

this.renderResults(JSON.parse( event.target.response ).assets);

Then, inside our class, we’ll add that render method to display all of the thumbnails on our page, as the following listing shows.

Listing 3.3. Render results of the HTTP request in our component
renderResults(assets) {                                                 1
   let html = '';
   for (let c = 0; c < assets.length; c++) {                            2
       html += '<img src="' + assets[c].thumbnail.url + '" width="200"
       height="150" />';                                                3
   }
   this.innerHTML = html;                                               4
}

  • 1 The list of results is passed into our render function.
  • 2 Loops through the result list
  • 3 For each asset, adds a thumbnail image
  • 4 After the HTML string is built, adds it all to the component

All we’re doing here is looping through our asset array, grabbing the thumbnail URL, making it the source of an image element, and adding that to a long string of HTML. Once finished, we set this long HTML string to our component’s innerHTML.

Of course, there are other ways to do this, rather than constructing strings. We could create a new image element with each loop.

Listing 3.4. Alternate way to render results
renderResults(assets) {
   for (let c = 0; c < assets.length; c++) {          1
       const img = document.createElement('img');     2
       img.src = assets[c].thumbnail.url;
       this.appendChild(img);                         3
   }
}

  • 1 Loops through our asset results list the same way as before
  • 2 Creates an image element each time, rather than using an HTML string
  • 3 Appends each element to the DOM, one at a time

I personally like the string approach for these cases better. You can create a big chunk of HTML and have it hit your DOM at the same time, rather than having one element per loop iteration. Also, HTML is a bit easier to read, especially when we get into template literals later on. A big downside to creating each element one by one in the loop is that with each one, you are causing the browser to re-parse and re-render that entire block. The same would happen if you were adding each image one at a time and setting innerHTML after each. It will likely be better to stick with an HTML string that gets built up over time and then set all at once to innerHTML.

3.1.5. Styling our component

If you run the example now, you’ll see some fairly large images in a vertical list, as figure 3.3 shows. This is not what we necessarily want for a visual results display, so let’s make the images smaller and place them in nice wrapping rows using some CSS, as in the following listing.

Figure 3.3. Our image results from poly.google.com before styling. They just flow down the page and force scrolling to see more than a few, because they are too large.

Listing 3.5. CSS to style our poly-search component
<style>
   poly-search {
       border-style: solid;               1
       border-width: 1px;
       border-color: #9a9a9a;
       padding: 10px;                     2
       background-color: #fafafa;         3
       display: inline-block;             4
       text-align: center;
   }

   poly-search img {
       margin: 5px;                       5

   }
</style>

  • 1 Gives a nice subtle border around our entire element
  • 2 A gap between the edges of our element and the inner results we are displaying
  • 3 A background color to pair with the border, separating the element from the page
  • 4 Allows elements to flow horizontally and wrap to the next line when out of room
  • 5 Spacing between images

For this listing, I’ve simply put the style in our <head> tag, as you would normally do with CSS. Coupling style within the scope of each Web Component is definitely something we’ll get to later on, but we’ll just go simple right now.

Already, though, we are targeting our poly-search element with a CSS selector. This is perfectly valid! When you create your own custom element, you are really creating a custom element that works just like any other element would.

Running the example will give you the best picture of what this style is doing, but figure 3.4 is a visual approximation of what we accomplished, followed by some explanation of what we did with our CSS.

Figure 3.4. Our nicely styled and centered image grid. Images are smaller, have a nice gap between them, and are set against a subtle, off-white background with a gray border.

Here’s our entire styled example.

Listing 3.6. Our entire working Web Component, fully styled
<html>
<head>
   <meta charset="UTF-8">
   <title>Google Poly Search</title>
   <script>
       class PolySearch extends HTMLElement {                   1
           connectedCallback() {
               this.doSearch();
           }

           doSearch() {                                         2
               const url =
     'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key=
            <your_api_key>';
               const request = new XMLHttpRequest();
               request.open( 'GET', url, true );
               request.addEventListener( 'load', (event) => {
                   this.renderResults(JSON.parse
                     ( event.target.response ).assets);
               });
               request.send();
           }
           renderResults(assets) {                              3
              let html = '';
              for (let c = 0; c < assets.length; c++) {
                  html += '<img src="' + assets[c].thumbnail.url +
                  '" width="200" height="150" />';
              }
              this.innerHTML = html;
           }
       }
       customElements.define('poly-search', PolySearch);
   </script>

   <style>                                                      4
       poly-search {
           border-style: solid;
           border-width: 1px;
           border-color: #9a9a9a;
           padding: 10px;
           background-color: #fafafa;
           display: inline-block;
           text-align: center;
       }

       poly-search img {
           margin: 5px;
       }
   </style>
</head>

<body>
<poly-search></poly-search>                                    5
</body>
</html>

  • 1 Web Component definition
  • 2 Search function call
  • 3 Renders the results
  • 4 Component CSS
  • 5 Uses the component on the page

The basics are now in place, and we have something that works visually, but it isn’t very useful yet as a search component.

3.2. Making our component configurable

Now, let’s revisit our glaring problem, and the whole point of this chapter. This component isn’t reusable at all. For one, even if I gave you my API key, there’s no way to properly set it in the component. Second, we’re always searching for “parrots.” There’s no way to pass this search term to our component, so if someone on your team used this component you built, they would have to go in and directly modify the URL string:

const url =
    'https://poly.googleapis.com/v1/assets?keywords=parrot&format=OBJ&key=<y
    our_api_key>';

3.2.1. Creating our component API with setters

Let’s start by breaking that URL string up a little. We’re going to do this in two different ways, which will eventually complement one another. The first method we’ll explore is to make getters and setters for the API key and search term.

Inside our class, we can add this listing.

Listing 3.7. Getters and setters for our component’s configurable options
set apiKey(value) {            1
   this._apiKey = value;
   this.doSearch();
}

set searchTerm(value) {        2
   this._searchTerm = value;
   this.doSearch();
}

  • 1 Setter for API key
  • 2 Setter for search term

Without a matching getter, JS would throw an error if we tried to read, or “get,” the property. However, we could easily create a getter as well:

get searchTerm() {
   return this._searchTerm;
}

So far, though, getters aren’t really necessary; we just need to inject the search term and API key variables into our component, as shown in figure 3.5.

Figure 3.5. Using setters on our component from outside-in lets us perform logic and set a value, but also keep the component API simple.

Breaking things up like this makes sense. You’ll likely need to set the API key only once, but as the user keeps searching for different things, the search term will be updated quite a bit.

3.2.2. Using our API from the outside looking in

With the code in listing 3.7 in place, when we set that property from the outside, it will run the function. In this regard, if you didn’t know the code in this class, you’d think you were working with a simple variable, thanks to our setter methods. You also might notice that I’m using underscores (_) in my variable names. This doesn’t mean anything special, but since JS doesn’t have the notion of “private” variables (aside from the exciting new class fields feature in the latest version of Chrome), or variables that you’re not allowed to access from outside your class, I use underscores to indicate that we don’t intend for these variables to be accessed from the outside. Using underscores can be a point of contention for some and is regarded as an older practice. If you’d like to dive deeper on this concept, please refer to the appendix. Regardless, in this case, _searchTerm is our internal variable that we’re using, while searchTerm is the setter for that variable.

By using a setter, we’re not just setting this searchTerm property. When setting it from outside our component class here, that’s just what it looks like to the user of our component’s API. Instead, by using a setter method, we inject some logic to both set that internal property and run our doSearch() method to fire the HTTP request.

Now, if you were to write some JS in your script tag outside the component class, you could write the following to first select your component and then set each property (only after the component has been properly created, of course):

document.querySelector('poly-search').apiKey = '<your_api_key>';
document.querySelector('poly-search').searchTerm = 'parrot';

Of course, if we ran a search without an API key or without a search term, our search would fail, so in the following listing, we can wrap our search method in an if statement to make sure both variables are present before we search.

Listing 3.8. Wrapping the search method with an if statement
doSearch() {
   if (this._apiKey && this._searchTerm) {                                1
       const url = 'https://poly.googleapis.com/v1/assets?keywords=' +
         this._searchTerm + '&format=OBJ&key=' + this._apiKey;
       const request = new XMLHttpRequest();
       request.open( 'GET', url, true );
       request.addEventListener( 'load', (event) => {
           this.renderResults(JSON.parse( event.target.response ).assets);
       });
       request.send();
   }
}

  • 1 Checks that both API key and search term are present

Giving our components an API like this is a good exercise, but for this particular use case, there is another method for passing data: attributes. We use attributes all the time in web development. In fact, that src attribute to set the thumbnail URL in each image is just one example. Even just setting the style of an element using class or the href link for a link tag are attribute examples.

3.3. Using attributes for configuration

Using attributes on Web Components is so obvious, you might overlook it in favor of the getter/setter approach. We use attributes so often that we might not think of them as something that can be used for the inner workings of your Web Component.

3.3.1. An argument against a component API for configuration

With the getter/setter API approach, there is some complexity involved that isn’t really needed. For one, having to wrap the search method with an if/then to check that the apiKey and searchTerm are set is good practice when a developer forgets to set one or the other, but it would be nice if both properties were immediately available when the component is used as intended.

The other annoyance is having to use JS at all to set these properties. If these properties were attributes on the HTML tag, we wouldn’t have to set the apiKey and searchTerm over two separate lines. In more complex applications, it can be hard to track down where you set these in your code. Also, there may be timing issues with your component. Perhaps your component hasn’t been properly created yet when you happen to call these setters. If this happened, it’s possible that your values would just be lost!

These are definitely manageable concerns—but let’s focus on attributes now.

3.3.2. Implementing attributes

Let’s change things up a bit. First, let’s get rid of our setters and our JS to use those setters. We don’t need them. Next, we’ll add our attributes to our custom element tag:

<poly-search apiKey="<your_api_key>"
             searchTerm="parrot">
</poly-search>

Now, we’ll swap in some JS to get our attributes in place of using our variables. Let’s keep the if/then check in the next listing just in case the user of our component forgets to use one attribute or the other.

Listing 3.9. Using attributes for configurable options in our search method
doSearch() {
   if (this.getAttribute('apiKey') && this.getAttribute('searchTerm')) {
       const url = 'https://poly.googleapis.com/v1/assets?keywords=' +
     this.getAttribute('searchTerm') + '&format=OBJ&key=' +
     this.getAttribute('apiKey');                                 1
       const request = new XMLHttpRequest();
       request.open( 'GET', url, true );
       request.addEventListener( 'load', (event) => {
           this.renderResults(JSON.parse( event.target.response ).assets);
       });
       request.send();
   }
}

  • 1 Uses attributes instead of properties for the configuration options

Lastly, since attributes are available as soon as the element is created, we can do an initial search right away when our component is added to the DOM using connectedCallback:

connectedCallback() {
   this.doSearch();
}

For brevity’s sake, I’ll leave out our CSS as we look at the current state of our component in the following listing.

Listing 3.10. Our complete (minus styling) component example using attributes
<html>
<head>
   <title>Google Poly Search</title>
   <script>
       class PolySearch extends HTMLElement {
           connectedCallback() {                                     1
               this.doSearch();
           }

           doSearch() {
                if (this.getAttribute('apiKey') &&
                this.getAttribute('searchTerm')) {
                   const url =
     'https://poly.googleapis.com/v1/assets?keywords=' +
     this.getAttribute('searchTerm') + '&format=OBJ&key=' +
     this.getAttribute('apiKey');                                    2
                   const request = new XMLHttpRequest();
                   request.open( 'GET', url, true );
                   request.addEventListener( 'load', (event) => {
                       this.renderResults(
                                JSON.parse( event.target.response ).assets);
                      });
                   request.send();                                   3
               }
           }

           renderResults(assets) {
              let html = '';
              for (let c = 0; c < assets.length; c++) {
                  html += '<img src="' + assets[c].thumbnail.url +
                  '" width="200" height="150" />';                   4
              }
              this.innerHTML = html;                                 5
           }
       }

       customElements.define('poly-search', PolySearch);
   </script>
</head>

<body>
<poly-search apiKey="<your_api_key>"
searchTerm="parrot">                                                 6
</poly-search>

</body>
</html>

  • 1 When the component is added, runs the search function
  • 2 If both search term and API key are set, adds them to the search endpoint
  • 3 Send the HTTP request
  • 4 Appends an image element to the HTML string for every asset
  • 5 Sets our component’s HTML to the generated string
  • 6 Declares the component on the page with the API key and search term

The component is now pretty functional, but the customization we’ve done only goes so far. That search term will likely change frequently; we’ll need to watch for changes.

3.3.3. Case sensitivity

Note that while I used an uppercase “K” in apiKey, and an uppercase “T” in searchTerm, attributes themselves are not case-sensitive. We could absolutely rewrite our tag like this, and it wouldn’t affect things at all (though there is a good reason for keeping things all lowercase, which we’ll get to in a bit):

<poly-search apikey="<your_api_key>"
            searchterm="parrot">
</poly-search>

3.4. Listening for attribute changes

There’s one remaining problem in regard to our use case, though. It’s true that our API key will likely never change in our web app, but we do want users to input text and search for things. Before we get into solving that problem, let’s create a typical text input that lets a user enter a search term. This aspect is outside of our Web Component, so it’s not a lesson in Web Components per se, just something to help us demonstrate and solve our attribute problem.

3.4.1. Adding text input

With this in mind, let’s change the contents of our <body> tag.

Listing 3.11. Text input for our component
<body>
    <label>Enter search term: </label>
    <input type="text" onchange="updatePolySearch(event)" />
    <br /><br />

    <script>
       function updatePolySearch(event) {
           document.querySelector('poly-search').setAttribute('searchterm',
             event.target.value);
       }
    </script>

    <poly-search apikey="<your_api_key>" searchterm="parrot">

We’ve now added a text input with an onchange event listener. Preceding that, we have a simple label, just to give context in our UI on what that text input is actually doing. I don’t typically have inline JS like this on a tag, but for such a simple demonstration, it’s easier to show it this way. The onchange event occurs only when the user “submits” the text, meaning when they press the Enter key or click off the field.

The function that it calls, updatePolySearch, captures the event that gets sent, which includes the target, or which element sent the event. We can query event.target.value to get the new search term that the user typed in. From there, we can set the searchterm attribute of our Web Component.

Feel free to try this out right now! If you open your browser’s development tools to show the live view of the elements on the page, you can see our <poly-search> searchterm attribute changing in real time after we change our text input.

Unfortunately, just updating the attribute doesn’t cause the search to rerun and update our results. We have to do this ourselves. This brings us to our second Web Component lifecycle method: attributeChangedCallback. Our first lifecycle method, of course, was connectedCallback, but now we’re ready to get a bit deeper.

3.4.2. The attribute changed callback

The attributeChangedCallback method is like any other Web Component lifecycle method. You simply add the method in your class to override HTMLElement’s empty method, and it will be fired when an attribute is changed.

This method accepts three parameters: the name of the attribute that changed, the old value of the attribute, and the new value of the attribute:

attributeChangedCallback(attrName, oldVal, newVal)

Let’s integrate this into our Web Component and see what happens. I’m going to be a little evil here, but warn you up front. We’re going to integrate this, but it’s not going to work because of one missing detail that I’ll explain afterward.

The first thing to do is to get rid of the connectedCallback method in our class. We do this because, in our specific case, our connectedCallback method triggers a search. However, now our attributeChangedCallback will actually do this as well. Technically speaking, our attribute does change from nothing to something when our component starts up, so the attributeChangedCallback triggers. Also, we don’t have any logic to cancel our HTTP request before triggering it again in our component—to keep things simple and bug free when both of these callbacks fire at virtually the same time, let’s just get rid of that connectedCallback.

Next, let’s add our attributeChangedCallback method.

Listing 3.12. AttributeChangedCallback to listen for changes to our searchterm
attributeChangedCallback(name, oldval, newval) {
   if (name === 'searchterm') {
       this.doSearch();
   }
}

Our callback here is really simple. If the attribute name being changed is searchterm, then run our search again. This aspect is case-sensitive. The name coming in will always be lowercase. This can be a bit confusing if you write your attribute in HTML in camel case, and then just write the name over here the same way. To avoid confusion, it’s wise to write our attributes in lowercase all the time.

As I was writing this, I accidentally made things a bit more complicated before I caught myself. I initially wrote the following code:

attributeChangedCallback(name, oldval, newval) {
   if (name === 'searchterm' && oldval !== newval ) {
       this.doSearch();
   }
}

I thought that I only wanted to call the search if the old value was different than the new value. There’s no sense in rerunning a search and wasting a network request if the value doesn’t change, right? Well, if the value didn’t change, this method wouldn’t get called in the first place, so doing this extra step is redundant.

Now that we’ve captured attribute changes and taken action when they change, it should work, right? Not yet! This is the part where I left out one little detail of how this method works. Before I explain what this is, let me give a little context and history.

3.4.3. Observed attributes

At the start of this chapter, I talked a bit about how common attributes are to everything we do in HTML. Each element has numerous potential attributes it can use that actually mean something. At minimum, elements will likely always have a class element for styling. And, of course, we can make up any attribute we want. With all of these potential attributes everywhere, it could be a huge waste of code execution to call attributeChangedCallback every single time something changes if we don’t care that it changed.

Back in v0 of the Web Components API, the attributeChangedCallback did just that: it was called each and every time something as common as a CSS class attribute changed. Early Web Component adopters thought this was a bit annoying and wasteful. So now, in v1 of the Web Components API, we need to tell our component what specifically to listen for.

Listing 3.13. Telling our component what attributes to watch changes for
static get observedAttributes() {
   return ['searchterm'];
}

If you’re not familiar with the static keyword for a class method, please refer to the appendix. In short, it’s a method called on the class definition, rather than on the created instance.

In this static method, we’ve set our observedAttributes to an array containing searchterm. If we wanted more attributes to be observed, we could simply add more elements to the array:

static get observedAttributes() {
   return ['searchterm', 'apikey', 'anotherthing', 'yetanotherthing' ];
}

With this last piece added to our example in listing 3.14, our example should run. This new code for watching our searchTerm attribute is depicted in figure 3.6. We now automatically load our results with the first search term of “parrot,” but when the user submits other terms, the results will update.

Figure 3.6. Before an attributeChangedCallback is fired inside your component as a result of an attribute change on your component’s markup, that attribute name must be in the observedAttributes list.

Listing 3.14. Complete component with attributes that respond to a text input field
<html>
<head>
   <title>Google Poly Search</title>
   <script>

       class PolySearch extends HTMLElement {                             1
           static get observedAttributes() {
               return ['searchterm'];                                     2
           }

           attributeChangedCallback(name, oldval, newval) {
               if (name === 'searchterm') {
                   this.doSearch();                                       3
               }
           }

           doSearch() {                                                   4
               if (this.getAttribute('apiKey') &&
     this.getAttribute('searchTerm')) {
                   const url =
     'https://poly.googleapis.com/v1/assets?keywords=' +
     this.getAttribute('searchTerm') + '&format=OBJ&key=' +
     this.getAttribute('apiKey');
                   const request = new XMLHttpRequest();
                   request.open( 'GET', url, true );
                   request.addEventListener( 'load', (event) => {
                       this.renderResults(JSON.parse
                         ( event.target.response ).assets);
                   });
                   request.send();
               }
           }

           renderResults(assets) {                                        5
               let html = '';
               for (let c = 0; c < assets.length; c++) {
                   html += '<img src="' + assets[c].thumbnail.url +
                    '" width="200" height="150" />';
               }
               this.innerHTML = html;
           }
       }

       customElements.define(
             'poly-search', PolySearch);                                  6
   </script>

   <style>                                                                7
       poly-search {
           border-style: solid;
           border-width: 1px;
           border-color: #9a9a9a;
           padding: 10px;
           background-color: #fafafa;
           display: inline-block;
           text-align: center;
       }

       poly-search img {
           margin: 5px;
       }
       input {
           font-size: 18px;
       }
   </style>
</head>

<body>

<label>Enter search term: </label><input type="text"
       onchange="updatePolySearch(event)" />                              8
<br /><br />

<script>
   function updatePolySearch(event) {                                     9
       document.querySelector('poly-search').setAttribute('searchTerm',
         event.target.value);
   }
</script>
<poly-search apikey="<your_api_key>"                                      10
            searchterm="parrot">
</poly-search>

</body>
</html>

  • 1 Component class
  • 2 Watched attribute
  • 3 When watched attribute changes, runs the search request
  • 4 Search request, which uses the API key and search term
  • 5 Renders all assets
  • 6 Map tag name to component class
  • 7 Component CSS
  • 8 Input field to allow user to type a search term
  • 9 As input field changes, sets the searchTerm attribute on our component
  • 10 Component added to page with API key set and starting search term set

With that, we’ve allowed our component to react to changes. It doesn’t really make sense for us to react to API key changes because the API key is typically something that never changes. That search term is going to change all the time, though, so we definitely needed a way to react to it.

3.5. Making more things even more customizable

Let’s now up our customization game! We can do some small style things, such as set the image size and component background color.

3.5.1. Using hasAttribute to check if an attribute exists

In listing 3.15, I’m being a bit of a lazy developer. I don’t expect that the image sizes or background color will need to change at runtime—only when we’re initially writing the HTML. So, I’m not listening for attribute changes; instead, I’m simply setting these style properties when the component is added to the DOM.

Listing 3.15. Adding attributes for size and background color
connectedCallback() {
   if (this.hasAttribute('thumbheight')) {                                 1
       this._thumbheight = this.getAttribute('thumbheight');
       this._thumbwidth = (this.getAttribute('thumbheight') *
         1.3333 /*aspect ratio*/);
   } else {                                                                2
       this._thumbheight = 150;
       this._thumbwidth = 200;
   }

   if (this.hasAttribute('backgroundcolor')) {                             3
       this.style.backgroundColor = this.getAttribute('backgroundcolor');
   }
}

  • 1 If the thumbheight attribute is set, uses it for image-sizing, and calculates the width as well
  • 2 If not set, uses default/hardcoded values.
  • 3 If the background color attribute is set, adjusts the style of the component right away.

I’m also not forcing the component’s user to have these attributes. Instead, I’m checking if the developer used the attribute in their markup by using hasAttribute and, if so, set these properties. If not, we have fallback values either with JS for the size or using the pre-existing style in CSS for background color.

To use my size properties, I’ve edited the image-rendering method as in the following listing.

Listing 3.16. Rendering our thumbnails with configurable sizes
renderResults(assets) {
   let html = '';
   for (let c = 0; c < assets.length; c++) {
       html += '<img src="' + assets[c].thumbnail.url + '" width="' +
        this._thumbwidth + '" height="' +
        this._thumbheight + '"/>';              1
   }
   this.innerHTML = html;
}

  • 1 Uses the height and width properties to control the image size

As we’ve added stylistic customization, you can probably imagine so much more! Certainly, we could customize borders, spacing, and so on. There’s one last thing we’ll customize, and that’s the search endpoint.

3.5.2. Fully customizing the HTTP request URL for development

This is also the point at which I’m going to make readers who didn’t want to sign up for an API key happy. We’re going to break up the HTTP request URL in the following listing. We’ll do this by separating out the base of the URL as well as the 3D object format for good measure.

Listing 3.17. Breaking apart our HTTP request URL to be even more configurable
doSearch() {
   if (this.getAttribute('apiKey') && this.getAttribute('searchTerm')) {
       const url = this.getAttribute('baseuri') +                          1
'?keywords=' + this.getAttribute('searchTerm') + '&format=' +
 this.getAttribute('format') + '&key=' + this.getAttribute('apiKey');
       const request = new XMLHttpRequest();
       request.open( 'GET', url, true );
       request.addEventListener( 'load', (event) => {
           this.renderResults(JSON.parse( event.target.response ).assets);
       });
       request.send();
   }
}

  • 1 Adds base URI as a configurable option to allow calling a different search destination

With the following tag, we can start using all of our customization options.

Listing 3.18. Adding the baseuri attribute to the component tag
<poly-search apikey="<your_api_key>"
            format="OBJ"
            thumbheight="50"
            backgroundcolor="red"
            baseuri=
              "https://poly.googleapis.com/v1/assets"   1
            searchterm="parrot">
</poly-search>

  • 1 Specifies the search endpoint in the component’s attributes

We can now tweak the baseuri attribute to be something else. Of course, different search services will have different APIs and result formats, but we can test our setup without Google by pointing to a JSON file that we host:

  baseuri="http://localhost:8080/assets.json"

This will differ, of course, depending on how you’ve set up your development server (it could be localhost, it could be something else, and port 8080 is common, but it differs wildly depending on your setup).

3.5.3. Best practice guides

Because we’ve now covered both getters/setters and attributes for working with data, which one should we use? Really, it’s up to you, but there are some emerging best practices. It’s a bit too early to take these best practices as mandates, but there are some good ideas, especially if you intend to share your components with other people. One resource is an incomplete working draft: https://github.com/webcomponents/gold-standard/wiki. Google has also published some best practices that are further along: https://developers.google.com/web/fundamentals/web-components/best-practices.

3.5.4. Avoiding attributes for rich data

Within the Google Web Components guide, there are a few best practices for attributes. One such practice is to not use attributes for rich data such as arrays and objects.

Let’s say, for example, that you have a very complex application, and for some of your Web Components, setup is insanely complex. Perhaps you have 50 or more properties to use for configuration—or your configuration data needs to be represented as a nested structure:

{
    Tree: {
        Branches: [
            { branch: {
               leaves: [
                { leaf: "leaf"},
                { leaf: "leaf"},
                { leaf: "leaf"},
               ]
              }
            }
        ]
    }}

Either way, separating out these properties for individual attributes would be overwhelming or impossible.

We can actually stringify a JSON object and shove it into an attribute on our tag:

<my-element data="{"Tree": {"Branches": [{"branch": {"leaves": [{"leaf":
    "leaf"},{"leaf": "leaf"},{ "leaf": "leaf"}]}}]}}
    " my-element>

It’s probably easier to do this through code, however:

myElement.setAttribute('data', JSON.stringify(data));

To pull the data out, you’d then have to serialize that string to JSON:

JSON.parse(this.getAttribute('data'));

In the end, though, when you have this massive, ugly string in your DOM, your development tools get that much harder to read and put up roadblocks for understanding your DOM structure. In this case, perhaps it’s better to use a method or setter to pass your data to your component and avoid rich data attributes.

3.5.5. Property and attribute reflection

Another Google-suggested best practice is to do something called reflection for your attributes and properties. Reflection is the practice of using both getters and setters as well as attributes for your data, and always keeping them in sync with each other. Especially when handing your component off to other developers or sharing it with the world, users may expect a consistent component API.

Attributes are generally easier to work with when writing HTML, while with JS code, setting properties on the component is more concise and easier to use. In other words, JS developers will prefer writing yourcomponent.property = 'something'; and likely won’t prefer writing yourcomponent.setAttribute('property', 'something');. At the same time, someone writing HTML would prefer to just set the attribute in the markup.

When these two methods don’t do the same thing, or one is supported and not the other, it can get a bit confusing for your component’s consumer. That’s why, when setting a property through JS, the corresponding attribute should change on the element, and vice versa. When an attribute changes, getting the property after that should reflect the newest value.

One trap that Google has identified with its best practice guide is using attributeChangedCallback to update the setter, which Google is calling re-entrancy; it’s implemented as follows.

Listing 3.19. A pitfall for reflection from Google’s Web Components best practices guide
// When the [checked] attribute changes, set the checked property to match.
attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'checked')
    this.checked = newValue;                                            1
}

set checked(value) {
  const isChecked = Boolean(value);
  if (isChecked)
    // OOPS! This will cause an infinite loop because it triggers the
    // attributeChangedCallback() which then sets this property again.
    this.setAttribute('checked', '');                                   2
  else
    this.removeAttribute('checked');
}

  • 1 When the attribute changes, the setter is called.
  • 2 When the setter is called, the attribute is updated, causing an infinite loop.

In this example, taken straight from Google’s developer documentation, an infinite loop is caused. The setter is used and sets the attribute, but this causes the attributeChangedCallback to fire, which again uses the setter, which then changes the attribute . . . you get the point—it’s an infinite loop, and the flow can be seen in figure 3.7.

Figure 3.7. Re-entrancy is a bad way to implement property/attribute reflection. Setting the attribute when your getter is used causes an attributeChangedCallback to be fired, which can then set the property again, continuing on in an infinite loop.

A better way might be to use the attribute as the so-called “source of truth.” I’ve added reflection to the searchTerm property in our Poly search example with just an additional getter and setter, shown in the following listing.

Listing 3.20. Adding a getter/setter in addition to existing attributes for reflection
static get observedAttributes() {
    return ['searchterm'];
}
get searchTerm() {
    return this.getAttribute('searchTerm');          1
}
set searchTerm(val) {
    this.setAttribute('searchTerm', val);            2
}
attributeChangedCallback(name, oldval, newval) {
    if (name === 'searchterm') {
        this.doSearch();                             3
    }
}

  • 1 Getter will simply return access and return the attribute.
  • 2 Setter will set the attribute.
  • 3 When setting, the attributeChangeCallback fires and runs the search.

In this example, our getter simply returns the current attribute, while our setter sets the attribute. There are, of course, additional ways to accomplish reflection, but the important takeaway is that if you want to maximize the developer experience with your component, keep your attributes and properties consistent and synced with each other!

3.6. Updating the slider component

Now that we understand how to work with attributes to make a reusable component, and know about using attribute reflection to our advantage, it’s time to update the slider component from the last chapter to make it interactive and reactive to the attributes we give it or JS properties we set on it. Right now, our component class is pretty slim, especially after moving all of the CSS outside the component into a <style> tag. All it does is render HTML (two <div> tags); the next listing shows the slider minus the lengthy CSS.

Listing 3.21. Slider component (without CSS)
<html>
<head>
    <title>Slider</title>

    <script>
        class Slider extends HTMLElement {
            connectedCallback() {
                this.innerHTML = '<div class="bg-overlay"></div>
                <div class="thumb"></div>';
            }
        }

        if (!customElements.get('wcia-slider')) {
            customElements.define('wcia-slider', Slider);
        }
    </script>

    <style><!—- CSS was here --></style>
</head>
<body>
    <wcia-slider></wcia-slider>
</body>
</html>

Recall that we temporarily used two properties to control some of the component’s functionality, or, in other words, its API. Let’s formalize this API and list those properties here:

  • value—The current percentage value of the slider from 0–100
  • backgroundcolor—A hexadecimal color of the topmost background layer

With those now defined, we can do two things. The first is to listen for changes to those attributes. We’ll be adding all of these functions right inside the Slider class.

Listing 3.22. Listening for attribute changes
static get observedAttributes() {
   return ['value', 'backgroundcolor'];              1
}

attributeChangedCallback(name, oldVal, newValue) {
   switch (name) {
       case 'value':
           this.refreshSlider(newValue);             2
           break;

       case 'backgroundcolor':
           this.setColor(newValue);                  3
           break;
   }
}

  • 1 Listens for both value and backgroundcolor attribute changes
  • 2 Reacts to changes in the slider value if set from outside the component
  • 3 Reacts to background color changes

The second thing to do is to intertwine those attributes with a proper JS API using reflection, as we’ve just learned. When one of these properties is set through the JS setter, the attribute is updated on the component. Likewise, when the attribute is set on the tag, this value can be retrieved through the matching getter. The next listing shows reflection in our component for these two attributes.

Listing 3.23. Getters and setters for the backgroundcolor and value properties
set value(val) {
   this.setAttribute('value', val);
}

get value() {
   return this.getAttribute('value');
}

set backgroundcolor(val) {
   this.setAttribute('backgroundcolor', val);
}

get backgroundcolor() {
   return this.getAttribute('backgroundcolor');
}

Remember, with reflection, our attributes are the “source of truth,” so these getters and setters simply set or get the attribute directly.

We’re almost ready to demo the slider for real! Referring back to listing 3.22, which holds the component class definition, remember the attributeChangedCallback. We have two methods that don’t exist yet. When receiving a new slider value, we see

case 'value':
   this.refreshSlider(newValue);
   break;

Likewise, with a new background color value, we have

case 'backgroundcolor':
   this.setColor(newValue);
   break;

Just so we can start seeing the results of our work, we should create these functions in the component class.

Listing 3.24. Functions to set the background color and slider value
setColor(color) {                                                         1
    if (this.querySelector('.bg-overlay')) {
           this.querySelector('.bg-overlay').style.background =
             `linear-gradient(to right, ${color} 0%, ${color}00 100%)`;
    }
}

refreshSlider(value) {                                                    2
    if (this.querySelector('.thumb')) {
            this.querySelector('.thumb').style.left = (value/100 *
              this.offsetWidth - this.querySelector('.thumb').offsetWidth/2)
              + 'px';
    }
}

  • 1 Sets the background color (a gradient from an opaque solid color to the same transparent color)
  • 2 Sets the current location of the slider thumb based on its value

Both functions likely need a bit of explanation, even though they are tiny. First, we’re checking to see if the DOM element we’re changing exists. There’s a bit of a timing issue with the attributeChangedCallback. Namely, it will fire first before connectedCallback if there are attributes on the component at the start. So, these DOM elements may not exist yet. Once we update this component to use the Shadow DOM later in the book, this problem won’t exist. This is also the reason we need to add a couple of lines to the connectedCallback, to make sure the initial attributes are acted on:

this.setColor(this.backgroundcolor);
     this.refreshSlider(this.value);

Next, when setting the color, the color value we get is a hexadecimal value (complete with the hash at the beginning). At the beginning, or 0% stop of the gradient, we can use this color value as normal. In our demo, it’s red, or #ff0000. The second color stop, at 100%, should be the same color but completely transparent. With the exception of Edge, every modern browser supports adding an additional “00” at the end to indicate the transparency to complement the red, green, and blue two-digit values in the larger hexadecimal code. We’ll worry about Edge later!

The refreshSlider function is pretty easy math. We calculate the thumbnail’s horizontal location by taking the fraction (percent divided by 100) of the component’s overall width. The slightly tricky part here is that we don’t actually want to position from the leftmost edge of the thumbnail. Instead, the dead center of the thumbnail should indicate the value. To center it, we need to subtract by half the width of the thumb graphic.

With these last updates, even though we don’t have interactivity, at least our attributes cause updates to the component. We can now load the HTML file and see something that looks like figure 3.8.

Figure 3.8. The slider component so far

What’s cool is that, even if we don’t have interactivity yet, the attributes on the demo can be changed. When the page is refreshed, you’ll see the new color and slide percentage. How about a blue background at 70%?

<wcia-slider backgroundcolor="#0000ff" value="70"></wcia-slider>

We’re almost done! The next step is to make that thumbnail draggable.

Let’s finish our component by adding some mouse listeners to the components. These three listeners can be seen in the next listing.

Listing 3.25. Adding three event listeners to handle mouse move, up, and down
connectedCallback() {
    this.innerHTML = '<div class="bg-overlay"></div><div
     class="thumb"></div>';

    document.addEventListener('mousemove',
            e => this.eventHandler(e));                              1

    document.addEventListener('mouseup', e => this.eventHandler(e));
    this.addEventListener('mousedown', e => this.eventHandler(e));

    this.refreshSlider(this.value);                                  2
    this.setColor(this.backgroundcolor);
}

  • 1 Mouse listeners for enabling slider dragging
  • 2 Due to timing issues with attributeChangedCallback firing first, refresh the slider and color now.

For mouse-down events, we only really care when the user clicks on the slider component. Even when clicking outside the thumbnail, it should snap to the horizontal location in the slider. Mouse-up events need to be caught everywhere on the overall web page. If the user clicks inside the component, but then the mouse drags outside, the user should still be able to release the mouse button, releasing the thumbnail. Likewise, for the mouse-move events, even when our mouse is dragging outside of the component, the thumbnail should still follow (the best it can within the confines of the slider).

All that’s left now is to add some code for our new eventHandler method.

Listing 3.26. Function to handle events and a function to update the slider percentage
updateX(x) {
   let hPos =                                          1
   x - this.querySelector('.thumb') .offsetWidth/2;
   if (hPos > this.offsetWidth) {                      2
       hPos = this.offsetWidth;
   }
   if (hPos < 0) {
       hPos = 0;
   }
   this.value = (hPos / this.offsetWidth) * 100;       3
}

eventHandler(e) {
   const bounds = this.getBoundingClientRect();
   const x = e.clientX - bounds.left;                  4

   switch (e.type) {
       case 'mousedown':                               5
           this.isDragging = true;
           this.updateX(x);
           this.refreshSlider(this.value);
           break;

       case 'mouseup':                                 6
           this.isDragging = false;
           break;

       case 'mousemove':                               7
           if (this.isDragging) {
               this.updateX(x);
               this.refreshSlider(this.value);
           }
           break;
   }
}

  • 1 Offsets the horizontal position to use the center of the thumbnail
  • 2 Restricts horizontal position to confines of component bounds
  • 3 Calculates the percentage horizontal position and sets the value attribute through the setter API
  • 4 Calculates horizontal position relative to left edge of the component
  • 5 On mousedown, sets a boolean to indicate the user is dragging, updates the “value” attribute, and updates the slider position
  • 6 On mouseup, sets the boolean to false to indicate the user is no longer dragging
  • 7 On mousemove, if the boolean indicates the user is dragging, updates the “value” attribute and updates the slider position

With this last addition, our slider component is fully functional! We can even crack open the dev tools, like in figure 3.9, to watch the value attribute change as we drag the thumbnail.

Figure 3.9. Using the slider component and watching the value attribute update in the dev tools

The slider component isn’t done yet! It’s really not shareable if someone else on your team wanted to use it. This will involve bringing the relevant CSS into the component (as real CSS, not the JS style setting like in the last chapter) and separating out these visual concerns from the main component class.

Summary

In this chapter, we’ve expanded our Custom Element API methods repertoire to both connectedCallback and attributeChangedCallback. In the next chapter, we’ll talk through the rest of the Web Component lifecycle in depth and compare it to similar component lifecycles on both the web and beyond. Also in this chapter, you learned

  • How to use attributes to call an endpoint for a search service, with ideas on which attributes need to be watched and which don’t, including how to actually watch the attributes in practice using the Web Components API
  • What reflection is and how it can make your component more robust, such that it can be used through its tag as well as through a custom JS API, and how to avoid the problem of re-entrancy
  • Strategies for when to use attributes versus a custom API and when to use both for a better developer experience for your component’s consumers
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset