Reading Time: 6 minutes

Background

In my opinion (from developer POV) Google Chrome is the best browser nowadays. One of its best features is supporting almost all of the latest trends and technologies exist in JS world. It also supports Shadow DOM API which is the topic of this post. It makes more meaning and sense in the context of Chrome Extensions. In this post I'll show you how to create simple extension which will be responsible for injecting small dropdown button to certain webpages which actions are defined in the extension script. I will also show you how to use ShadowDOM spec in order to isolate HTML elements, CSS sheets and JS scripts from target webpage.

Final, working example of this tutorial can be found here:

Github

What ShadowDOM actually is?

Its rising new web standard which helps to encapsulate web components. Components and encapsulation are the key here

  • Component - for some time in the world of front end we could notice constant following to split webpages into as small parts as possible. These small parts can be used frequently. This is the place where components come to the rescue. However, not like you can see in React for example, but which are provided natively, by web browser - and this is what we call a Shadow DOM. This technology is growing fast and will continue to grow as is systematically developed.
  • Encapsulation - closely related to Web Components. It helps elements to exist independently of one another, but most of all it does not allow to "flow out" css outside component

Besides, ShadowDOM helps to make more intuitive CSS classes by separating your CSS namespace from the CSS namespace of the page. What that means is that, for example, if page defines the “large” class, you can define your own “large” CSS class and it will be completely independent of the page, thanks to ShadowDOM isolated scope. ShadowDOM also allows you to write more intuitive JS, because document.querySelector is also isolated from the page.

Our goal

We are about to make script which, when installed in the chrome browser as an extension, will allow us to inject a dropdown to selected pages. Dropdown will contain options to select eg. wishlist, which will be the list of your dream properties. Similar functionality was developed by us for one of our customers - HomeAhead

The problem

This is how it looks WITHOUT ShadowDOM

Chrome extension without the use of ShadowDOM

And there WITH ShadowDOM

Enhanced Chrome extension with the use of shadowDOM

Takeoff!

Let’s start by downloading the boilerplate of Chrome Extension, I suggest this one: https://extensionizr.com/. Then, let’s load the example from settings chrome://extensions/ (remember to be in dev mode to be able to load unpacked). The first step is to edit in manifest.json information about sites which we would like to change. It’s necessary to show user which sites we will be controlling, so the user can grant us required permissions.

We are changing permissions key:

"permissions": [
"*://www.homes.com/*"
],

In this case we will be injecting to homes.com

Next step is to delegate specific scripts to run in preset subpages. Key content_scripts is responsible for injecting files to given pages:

"content_scripts": [
{
"matches": [ "*://www.homes.com/property/*", "*://homes.com/property/*" ],
"js": [ "src/inject/dropdown.js", "src/inject/button.js", "src/inject/homes.js" ],
"run_at": "document_start"
}
],

In dropdown.js there will be a base class associated with all component's logic. button.js will be the place where we are intializing base Dropdown class, choosing holder for container and assigning callbacks. Finally, we're firing homes.js - here we are waiting for full page to load and inject button.

Code it!

[ecko_contrast]Every line of JS code we are making, we are doing in ES6 aka ES2015 without any transpilers. Why? Beacause Chrome is the best and has implemented everything :) And of course without the jQuery library.[/ecko_contrast]

Home.js Class

This script is firing as the last one. It injects Dropdown only when DOM is fully loaded.

var readyStateCheckInterval = setInterval(function() {
if (document.readyState === "complete") {
clearInterval(readyStateCheckInterval);
const target = document.getElementsByClassName('property-pricetag')[0]
const button = new Button()
button.createButtonWrapper(target)
}
}, 1000);

Here we're checking in 1sec interval for full page load. When it's complete then thanks to getElementsByClassName we're selecting DOM elements which will be our anchor to inject ShadowDOM.

Button.js Class

It’s simple class responsible for initializing Dropdown and all its methods which are required for all components to work. In above listing we can see creating new Button class object and below calling its method createButtonWrapper. That’s what we are implementing in this file

In constructor initialize Dropdown class

this.dropdown = new Dropdown()

and set callback to desired user action, in this case click on selected option. Dropdown returns what you want, eg. console.log:

this.dropdown.setOnSelectItemCallback(value => {
console.log('callback', value)
})

Next method we're implementing is createButtonWrapper which argument is node to DOM element we are attaching:

createButtonWrapper(DOMTarget) {
const wrapper = document.createElement('div')
wrapper.setAttribute('id', 'shadowdom-container')

DOMTarget.appendChild(wrapper)

this.dropdown.initShadowDOM(wrapper)
this.dropdown.render()
}

In penultimate line we're passing DOM element, our wrapper, which will be container for isolated DOM tree.

Dropdown.js Class

Remember zero jQuery notice? In this case it would result in unnecessarly increasing weight of extension and besides we are not doing "rocket science" here so we can improve our "raw DOM" skills, and for own pleasure :)

While constructor is simple, there are two things worth mentioning:

this.onSelectCallback = noop => noop
this.onTriggerClick = this.onTriggerClick.bind(this)

First, assigning anonymous function so that we won’t need to check if this onSelectCallback is created by end user. Next we're binding context, like in React, thanks to that we will have access to valid this scope.

Second method worth mentioning is initDropdownClick(). It's responsible for binding wrapper's click. Thanks to ShadowDOM we are sure that listener will be assigned to only one DOM element.

const trigger = this.shadow.getElementById('dropdown-trigger')
trigger.addEventListener('click', this.onTriggerClick)

Element dropdown-trigger is defined in createHTML() method in which we're storing all HTML of injected div as a string.

return `
<style>.wrapper { display: none; }</style>
<div class="wrapper" id="dropdown-wrapper">
<div class="label" id="dropdown-trigger">Select target</div>
<div class="list">
<div class="option" data-value="one">Option 1</div>
<div class="option" data-value="two">Option 2</div>
</div>
</div>
`

style tag with none is here because we won’t show broken dropdown to the end user. Why? Answer is simple, cause our CSSs are defined in separate file. Chrome Extensions API doesn’t allow us to include those. We are forced to make a GET call for this resource.

getStyles() {
const url = chrome.extension.getURL("css/styles.css")

fetch(url, { method: 'GET' }).then(resp => resp.text()).then(css => {
this.shadow.innerHTML += `<style>${css}</style>`
this.initListeners()
})
}

To make this method working we have to update manifest.json with additional rights.

"web_accessible_resources": [
"css/*"
]

This is specifying the paths for packaged resources that are expected to be usable in the context of a web page.

Value of selected dropdown is caught in onOptionClick() method. It reads value from data HTML5 attribute:

const value = option.getAttribute('data-value')

this.close()
this.onSelectCallback.call(null, value)

In the last line we're calling callback which is defined by user.

Last thing worth mentioning is closing dropdown on blur. Now, if we click outside the dropdown then nothing happens. Here is tricky way to handle this blur click - we're setting listener on whole document document.addEventListener('click', ...) and then we're checking if clicked target has id attribute of out container, if not then we are sure that the user clicked outside the dropdown and we can close it.

if (e.target.getAttribute('id') !== 'shadowdom-container') {
this.close()
}

To check if our extension is working correctly we have to go to sample property page on homes.com domain, eg. https://www.homes.com/property/119-13th-st-n-texas-city-tx-77590/id-500019320228/

So we have finally arrived to the end. As you can see thanks to ShadowDOM it’s very easy to create completely separate page element. It ensures that element will be looking the same on every page we are injecting to so we don’t care about making redundant !important declarations in css classes.

If you were lost at any point please post a comment and I will respond to it ASAP. Also, I encourage you to fork the repository and experiment with the code.

Protenders banners-1

Recent Posts