Web components are a new set of APIs built on web standards that are widely adopted by browsers (see browser support at webcomponents.org). They allow developers to make flexible custom components–but with that flexibility comes responsibility. In this two-part blog, we will outline what web components are and the specific accessibility considerations they have, so you can integrate web components into your own products with all users in mind. Stay tuned, we'll be publishing a second blog about accessibility for Web Components soon.
Web Components
Web components allow developers to make their own custom components with native HTML and JavaScript. They are built of three parts:
- Custom elements
- HTML templates
- Shadow DOM
Salesforce’s Lightning Web Components (LWC) component framework is built on top of web components to make it easy to create fast, lightweight components. Let’s explore an example web component to see how we can best leverage them.
Custom Elements
This is the custom tag itself, which extends either an existing tag (like HTMLButton) or the base HTMLElement.
For my example component, I am going to extend the base HTML element. I have to define the custom element for the browser and connect it to the CustomButton class I made (live finished CustomButton).
class CustomButton extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define('custom-button', CustomButton);
Right now, I have this awesome new tag <custom-button></custom-button>
, but it doesn’t have anything inside of it and it can’t do anything. There are a couple ways to build this component. I could add functionality directly to the custom tag, but in this example I will use HTML templates.
HTML Templates
There are two ways to create reusable snippets of HTML: <template>
and <slot>
elements.
Templates
Templates have display=”none” by default and can be referenced with JavaScript, which makes them good for HTML that will be reused in your component.
Looking at the CustomButton, using a template makes sense for now. I don’t need a lot of flexibility since it is just a button that developers can pass a custom string to.
To begin building my component, I add a template tag in the DOM (Document Object Model) and add a button inside of it. Then, in the constructor I append the contents of the template to the custom element itself.
let myTemplate = document.createElement('template');
myTemplate.innerHTML = `
<button>
<slot name="icon"></slot>
<span>Default text</span>
</button>
`;
class CustomButton extends HTMLElement {
constructor() {
super();
let shadowRoot = this.attachShadow({ 'mode': 'open' });
shadowRoot.appendChild(myTemplate.content.cloneNode(true));
}
}
window.customElements.define('custom-button', CustomButton);
My button template has a span inside of it with default text that the user can then replace by passing a string to the custom element with the text attribute.
I also added a connectedCallback function, which is a web component lifecycle function that happens when the component is connected to the DOM. In that function I set the innerText of the button to the value passed from the custom component.
I can use the CustomButton in my HTML like this:
<custom-button text="Click me!"></custom-button>
So now, if I use my CustomButton component, the browser DOM will look like this:
<custom-button text="Click me!">
<button>Click me!</button>
</custom-button>
Slots
Slots allow flexibility, since they let you put anything within them. This is especially useful if you need to allow consumers of your component to add custom HTML. One thing to keep in mind is that slots require that shadow DOM is enabled to work correctly.
For my CustomButton component, people might want to add an icon – so I can use a slot! I update the contents of the template to be:
<button>
<slot name="icon"></slot>
<span>Default text</span>
</button>
Someone using my button can add any icon in their HTML:
<custom-button>
<svg slot="icon" aria-hidden="true"> //nifty icon </svg>
</custom-button>
Which, if shadow DOM is enabled, the browser will render as:
<custom-button>
#shadow-root
<slot name="icon">
#svg
</slot>
<span>Default text</span>
<svg slot="icon" aria-hidden="true"> //nifty icon </svg>
</custom-button>
For more on the differences between the two, check out Mozilla’s article on templates and slots.
Since I have to use shadow DOM for the icon slot, the next step is to look into what the shadow DOM is and how it works.
Shadow DOM
Up until this point, when I talk about the DOM, it is the main DOM that the browser generates – which is also called the light DOM. If you view the page source of a site, you can see the light DOM, every HTML element on the page.
The shadow DOM is a scoped document object model tree that is only within your custom element. If shadow DOM is enabled in your component, the component’s elements are in a separate tree from the rest of the page.
No Shadow vs Open vs Closed
Web components don’t need to have shadow DOM enabled, but if it is enabled, it can either be open or closed.
If shadow DOM is not enabled: the component is in the main DOM. JavaScript and CSS on the page can affect the contents of the component.
<custom-button>
<button>Default text</button>
</custom-button>
If the shadow DOM is open: the main DOM can’t access the sub tree in the traditional ways, but you can still access the sub tree with Element.shadowRoot. document.getElementById, other query selectors, and CSS from outside the component will not affect it.
<custom-button>
#shadow-root (open)
<button>Default text</button>
</custom-button>
If the shadow DOM is closed: the main DOM cannot access the elements inside of the component at all. JavaScript and CSS from outside the component will not affect it.
<custom-button>
#shadow-root (closed)
<button>Default text</button>
</custom-button>
There are very few instances where having a fully closed shadow is necessary and the current industry standard is to use open shadow.
To look at the source code for the CustomButton example, I enable the open shadow DOM like this:
let myTemplate = document.createElement('template');
myTemplate.innerHTML = `
<button>
<slot name="icon"></slot>
<span>Default text</span>
</button>
`;
class CustomButton extends HTMLElement {
constructor() {
super();
let shadowRoot = this.attachShadow({ 'mode': 'open' });
shadowRoot.appendChild(myTemplate.content.cloneNode(true));
}
}
window.customElements.define('custom-button', CustomButton);
The contents of the template are now added to the shadow root, not directly to the custom element.
Finishing the Custom Button
The HTML is the way I want it to be, so it is time to make the CustomButton interactive. When people click the button, I want to toggle the aria-pressed attribute so users know if it is pressed.
let myTemplate = document.createElement('template');
myTemplate.innerHTML = `
<button>
<slot name="icon"></slot>
<span>Default text</span>
</button>
`;
class CustomButton extends HTMLElement {
constructor() {
super();
let shadowRoot = this.attachShadow({ 'mode': 'open' });
shadowRoot.appendChild(myTemplate.content.cloneNode(true));
this.button = this.shadowRoot.querySelector('button');
this.handleClick = this.handleClick.bind(this);
this.updateText = this.updateText.bind(this);
}
get ariaPressed() {
const value = this.button.getAttribute('aria-pressed');
return (value === 'true');
}
set ariaPressed(value) {
this.button.setAttribute('aria-pressed', value);
}
connectedCallback() {
this.button.addEventListener('click', this.handleClick);
if (this.hasAttribute('text')) this.updateText();
}
handleClick() {
this.ariaPressed = !this.ariaPressed;
}
updateText() {
let buttonSpan = this.button.querySelector('span');
buttonSpan.innerText = this.getAttribute('text');
}
}
window.customElements.define('custom-button', CustomButton);
Live version
This is the final code for my CustomButton, I have added a couple functions:
- get ariaPressed: returns the value of the aria-pressed attribute on the button inside the custom-button element
- set ariaPressed: sets the value of the aria-pressed attribute on the button inside the custom-button element.
- connectedCallback: adds an onClick listener when the component connects to the DOM.
- handleClick: toggles the value of ariaPressed when the button is clicked
Now, I can add my custom button to my HTML like this:
<custom-button id="important-button" text="Click me!"></custom-button>
And I can programmatically set the ariaPressed property like this:
document.getElementById('important-button').ariaPressed = true;
Conclusion
We now have a button component with a property called ariaPressed that can be set with JavaScript. The component combines custom elements, HTML templates, and shadow DOM all with plain JavaScript and HTML, no frameworks required! In part two, I will cover the accessibility concerns related to web components.
Top comments (1)
Excellent article Lee, Tx!