Accessibility for Web Components

Accessibility Concerns

In part one, we covered what web components are and how to build one. Now, we can look at how this new technology currently affects accessibility. It is important to note that these considerations are for web components and extend to Lightning Web Components as well.

Direct Descendancy

In HTML, you can define what an element is with a role (see: the ARIA Roles Model). This helps give users context on how to interact with the element.

There are explicit roles, which can be set with the role attribute. In this example, the <div> has role=”alert” so assistive technologies (AT) know to read it immediately when it appears on the page. 

<div role="alert">
  This is an alert!
</div>

There are also implicit roles (see: complete list of implicit roles). Some HTML elements don’t need the role attribute because it is already understood to have a role, like <ul> and <li> elements.

<ul> is implied to have role=”list”, and <li> has role=”listitem”. Browsers and AT know how to announce these based on this common understanding of what these elements are.

<ul>  // implied role="list"
  <li>content</li>  // implied role="listitem"
  ...
</ul>

The roles, however, can be brittle. If an element with an implicit role is not the direct child of the correct element, the browser may not understand the relationship between these elements anymore. 

<ul>  // implied role="list"
  <custom-list-item>  // implied role="presentation"
    <li>content</li>
  </custom-list-item>
  ...
</ul>

Now, the <li> elements in the <ul> are inside of a custom list item component. Browsers will no longer recognize this as a list with X number of items. Instead, it sees a list whose children have role=”presentation” (which means ‘no role’).

To fix this, you need to add the explicit roles instead. I have switched my list to use a <div> with role=”list”. For the list items, I can either make the inner HTML of my component a <div> with role=”listitem” or just add role=”listitem” to the custom element. Now that the roles are explicit, the browser understands the list and AT will read it correctly.

<div role="list">
  <custom-list-item>
    <div role="listitem">content</div>
  </custom-list-item>
  ...
</div>

// OR
<div role="list">
  <custom-list-item role="listitem">
    content
  </custom-list-item>
  ...
</div>

Global HTML attributes and properties on custom elements

When you are making a custom element, what properties and attributes do is not implemented by default.

If you set a global HTML attribute or property on your custom element and don’t add the proper Javascript, it will stay on the custom element unless otherwise specified. If the attribute is meant to be on the custom element, this is fine, but if the attribute needs to be passed to a child of the custom element things get more complicated. 

It is definitely not recommended to pass information to a child element with a global attribute or property name, but if for some reason it is required it needs the following to work:

  1. A setter function that takes the value from the custom element and puts it on the correct element.
  2. A getter function that returns the value from the correct element (not the one passed to the custom element)
  3. A function that checks if the value is on the custom element and removes it so that the browser isn’t confused.

This can be a lot to maintain because you will need to constantly be sure that the value doesn’t exist on both the custom element and the child element. 

Using the customButton example, if I were to pass the aria-pressed value like this:

<custom-button aria-pressed="true"></custom-button>

It would not work because the attribute would stay on the custom element, which has role=”presentation”, and not go to the actual button. The browser and assistive technology know what aria-pressed does on an element with role=”presentation”.

So, when a developer puts (or updates) aria-pressed on the customButton, the component needs to take the attribute off the custom-button tag and put it on the button inside instead. 

// in the HTML 
<custom-button aria-pressed="true"></custom-button>
// rendered in the browser
<custom-button>
  <button aria-pressed="true"> // contents of button </button>
</custom-button>

This is not an ideal way to code because it seems redundant to continually check if the attribute is in both places and is prone to regressions. It is simpler to call the attribute on custom element something else that won’t be confused for a global attribute and pass that value to the child element.

ID referencing + Shadow DOM

ID referencing is critical for accessibility. Many HTML attributes (aria-labelledby, aria-describedby, aria-controls, etc.) take an ID to another element to establish a connection between the two. Developers also use DOM queries to set focus with document.getElementById

When you can’t

If there is a shadow boundary, even if it’s open, referencing IDs from outside the component is impossible because the web component’s DOM is separate.

Let’s say I have a custom-input component with a unique ID and I try to associate it with a label in that label’s HTML for attribute, like this: 

<label for="my-input">City</label>
<custom-input>
  #shadow-root (open)
    <input id="my-input" type="text" />
</custom-input>

It will not work because when it is rendered, the input is in a separate DOM tree from the label, which means the browser can no longer link them together.

<label for={NOT A REF}>City</label>
<custom-input>
  #shadow-root (open)
    <input id={NOT A REF} type="text" />
</custom-input>

If there are any attributes that require IDs, they need to be within the same shadow boundary. So, for this custom-input example, I would pass a label attribute to the component instead of having it outside:

<custom-input label="City">
  #shadow-root (open)
    <label for="my-input">City</label>
    <input id="my-input" type="text" />
</custom-input>

When you can

If your component has a slot that is inside of the shadow boundary, it might seem like you cannot reference IDs within the slot, but you actually can! This is because the contents of slots are actually in the light DOM, not the component’s shadow DOM (even if the slot is within the shadow boundary!).

This is the form-element-container wrapper component that I can pass an input to, it standardizes the CSS for each of my form elements. In this component, the input element goes into a slot and I leave the label outside the component.

<label for="another-input">Country</label>
<form-element-container>
  <input id="another-input" type="text" slot="input-slot"/>
</form-element-container>

The browser will display the DOM like this:

<label for="another-input">Country</label>
<form-element-container>
  #shadow-root (open)
    <slot name="input-slot">
      #input
    </slot>
  <input id="another-input" type="text" />
</form-element-container>

The main drawback to the slots is that since they are in the light DOM, all styles and JavaScript on the page will affect the content in the slots so you can’t expect them to look or behave the same everywhere.

tabindex=”-1” and Custom Elements

tabindex=”-1” is a great tool! It is used to make something focusable and clickable, but not in the tab order. An example use case for it could be a toast: when the toast appears, you can focus the whole thing and then a user can tab to the close button.

Currently, if you put tabindex=”-1” on a custom element with shadow DOM enabled, it makes all of the child elements non-tabbable. This is unexpected, since tabindex usually does not propagate, and makes it so only people using a mouse can click the elements. So, for the toast example: if the toast is a custom element, putting tabindex=”-1” will prevent users from ever being able to tab to the close button within the toast container.

The way around this is to be careful about what elements you put tabindex=”-1” on and to be sure to architect your component so that it will never need to be on the custom element itself.

Accessibility Object Model

The Accessibility Object Model (AOM) project will theoretically fix the current accessibility issues with web components. The project is currently being developed, but there is no timeline on when it will be officially released and working on all browsers.

What the AOM project aims to do is make all the attributes properties, so that developers can directly manipulate the browser’s accessibility tree with JavaScript. This would fix the issues related to shadow DOM, direct descendancy, and id referencing.

So instead of doing:

<div id="weird-label">Very weird button</div>
<div role="button" tabindex="0" aria-labelledby="weird-label"></div>

You could add all of that with Javascript instead and not have to worry about shadow boundaries.

// HTML
<div id="weird-label">Very weird button</div>
<div role="button" tabindex="0" aria-labelledby="weird-label"></div>

// JavaScript
let weirdButton = document.getElementbyId('weird-button');
weirdButton.role = 'button';
weirdButton.tabIndex = 0;
weirdButton.ariaLabelledBy = document.getElementById('weird-label');

Conclusion

Given that the Accessibility Object Model is not finished or fully adopted by browsers, it is important to still make web components accessible built today. When you are architecting your new web components, be sure to include these considerations so everyone can use your components. 

Resources

About the Author 

Lee White is an accessibility engineer at Salesforce, where she focuses on Lightning Platform accessibility. You can follow her on Twitter @shleewhite.