It is critically important that content on the web be accessible. When native elements are not the solution, ARIA is the standard which allows us to describe accessible relationships among elements. Unfortunately, it's mechanism for this is historically based on IDREF
s, which cannot express relationships between different DOM trees, thus creating a problem for applying these relationships across a ShadowRoot
.
Today, because of that, authors are left with only incomplete and undesirable choices:
- Observe and move ARIA-related attributes across elements (for role, etc.).
- Use non-standard attributes for ARIA features, in order to apply them to elements in a shadow root.
- RequirE usage of custom elements to wrap/slot elements so that ARIA attributes can be placed directly on them. This gets very complicated as the number of slotted inputs and levels of shadow root nesting increase.
- Duplicating nodes across shadow root boundaries.
- Abandoning Shadow DOM.
- Abdandoning accessibility.
It is important that this be addressed and authors be able to establish, enable and manage important relationships.
This proposal introduces a reflection API which would allow ARIA attributes and properties set on elements in a shadow root can be reflected by their host element into the parent DOM tree..
This mechanism will allow users to apply standard best practices for ARIA and resolve a large margin of accessibility use cases for applications of native Web components and native Shadow DOM. This API is most suited for one-to-one delegation, but should also work for one-to-many scenarios. There is no mechanism for directly relating two elements in different shadowroots together, but this will still be possible manually with the element reflection API.
The proposed extension adds a new reflects*
(e.g.: reflectsAriaLabel
, reflectsAriaDescribedBy
) options to the .attachShadow
method similarly to the delegatesFocus
, while introducing a new content attribute auto*
(e.g.: reflectarialabel
, reflectariadescribedby
) to be used in the shadowroot inner elements. This has an advantage that it works with Declarative Shadow DOM as well (though, it requires another set of HTML attributes in the declarative shadow root template), and it is consistent with delegatesFocus
. The declarative form works better with common developer paradigm where they may not necessarily have access to a DOM node right where they are creating / declaring it.
<input aria-controlls="foo" aria-activedescendent="foo">Description!</span>
<template id="template1">
<ul reflectariacontrols>
<li>Item 1</li>
<li reflectariaactivedescendent>Item 2</li>
<li>Item 3</li>
</ul>
</template>
<x-foo id="foo"></x-foo>
const template = document.getElementById('template1');
class XFoo extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open", reflectsAriaControls: true, reflectsAriaActivedescendent: true });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define("x-foo", XFoo);
In the example above, x-foo
would be able to play the role of both the aria-controls
element and the aria-activedescendent
element for the input
, setting that basis for a combo box style interface.
For instance when reflecting aria-activeelement
it is desirable that readers know that it applies to the input
so that focus does not need to be thrown to a different element when updating the active element. Current workarounds include copying the DOM and referencing something in the same DOM tree in order to complete the reader relationship, while placing that content invisibly in the same place as the content that a pointer device user might leverage to interact with the same data.
Live example: TBD
This extension allows usage of attributes with Declarative Shadow DOM and won't block SSR.
Following the same rules to add declarative options from attachShadow
such as delegateFocus
, we should expect ARIA delegation to be used as:
<input aria-controlls="foo" aria-activedescendent="foo">Description!</span>
<x-foo id="foo">
<template shadowroot="open" shadowrootreflectscontrols shadowrootreflectsariaactivedescendent>
<ul reflectariacontrols>
<li>Item 1</li>
<li reflectariaactivedescendent>Item 2</li>
<li>Item 3</li>
</ul>
</template>
</x-foo>
In the example above, the <template shadowroot>
tag has the content attributes shadowrootreflectscontrols
and reflectsariaactivedescendent
, matching the options in the imperative attachShadow
:
this.attachShadow({ mode: "open", delegatesAriaLabel: true, reflectsAriaControls: true, reflectsAriaActivedescendent: true });
This mirrors the usage of delegatesFocus as:
<template shadowroot="open" shadowrootdelegatesfocus>
being the equivalent of:
this.attachShadow({ mode: "open", delegatesFocus: true });
For now, consistency is being preserved, but otherwise the ARIA delegation attributes can be simplified as:
<span id="foo">Description!</span>
<x-foo aria-label="Hello!" aria-describedby="foo">
<template shadowroot="open" reflectscontrols reflectsariaactivedescendent>
<input id="input" autoarialabel autoariadescribedby />
<span autoarialabel>Another target</span>
</template>
</x-foo>
Or even further by accepting a token list on reflects
or a similarly shaped attribute:
<span id="foo">Description!</span>
<x-foo aria-label="Hello!" aria-describedby="foo">
<template shadowroot="open" reflects="controls aria-activedescendent">
<input id="input" autoarialabel autoariadescribedby />
<span autoarialabel>Another target</span>
</template>
</x-foo>
Take this simplified "Editable Combobox With List Autocomplete Example" from the ARIA Authoring Practices Guide.
<label for="cb1-input">State</label>
<div class="combobox combobox-list">
<div class="group">
<input
id="cb1-input"
class="cb_edit"
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="cb1-listbox"
aria-activedescendant="lb1-ak"
/>
<button
id="cb1-button"
tabindex="-1"
aria-label="States"
aria-expanded="true"
aria-controls="cb1-listbox"
></button>
</div>
<ul id="cb1-listbox" role="listbox" aria-label="States">
<li id="lb1-al" role="option">Alabama</li>
<li id="lb1-ak" role="option">Alaska</li>
</ul>
</div>
Currently, to fully achieve the relationships outlined therein, if you wanted to convert this DOM to custom elements, you'd really only have the option to decorate the example:
<x-label>
<label for="cb1-input">State</label>
</x-label>
<x-combobox>
<x-input-group>
<x-input>
<input
id="cb1-input"
class="cb_edit"
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="cb1-listbox"
aria-activedescendant="lb1-ak"
/>
</x-input>
<x-button>
<button
id="cb1-button"
tabindex="-1"
aria-label="States"
aria-expanded="true"
aria-controls="cb1-listbox"
></button>
</x-button>
</x-input-group>
<x-listbox>
<ul id="cb1-listbox" role="listbox" aria-label="States">
<li id="lb1-al" role="option">Alabama</li>
<li id="lb1-ak" role="option">Alaska</li>
</ul>
</x-listbox>
</x-combobox>
While this offers some additional custom element-based control over the styles attributed to this UI, the approach continues to hoist the responsibility of building this DOM to the parent component or application.
When moving that DOM management responsibility to the individual custom elements themselves, the responsibility of the consuming developer begins to diminish, but the ID based relationships begin to change or become impossible. To support this, we've assumed the presence of the ARIA Attribute Delegation API in following code examples:
<x-label id="cb1-label">State</x-label>
<x-combobox>
<x-input-group>
<x-input
aria-labeledby="cb1-label"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="cb1-listbox"
aria-activedescendant="lb1-ak"
>
#shadow-root delegates="aria-labeledby role aria-autocomplete aria-expanded aria-controls aria-activedescendant"
<input
type="text"
auto-role
auto-aria-labeledby
auto-aria-autocomplete
auto-aria-expanded
auto-aria-controls
auto-aria-activedescendant
/>
</x-input>
<x-button
aria-labeledby="cb1-label"
tabindex="-1"
aria-expanded="true"
aria-controls="cb1-listbox"
></x-button>
</x-input-group>
<x-listbox
aria-labeledby="cb1-label"
options='[["Alabama", "lb1-al"], ["Alaska", "lb1-ak"]]'
></x-listbox>
</x-combobox>
Here we've moved from the for
attribute on a <label>
element to giving its host an ID so that
other elements can reference it via aria-labelledby
. This persists the accessible relationship,
but it removes the previously managed interactions, like clicking the <label>
focusing on the
input. We also see the aria-activedescendant
relationship broken as the ID lb1-ak
moves into
the shadow root of the <x-listbox>
element. This is the first place the ARIA Attribute Reflection
benefits the refactor of the pattern from raw DOM to custom elements.
<x-label id="cb1-label">State</x-label>
<x-combobox>
<x-input-group>
<x-input
aria-labeledby="cb1-label"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="cb1-listbox"
aria-activedescendant="lb1-ak"
>
#shadow-root delegates="aria-labeledby role aria-autocomplete aria-expanded aria-controls aria-activedescendant"
<input
type="text"
auto-role
auto-aria-labeledby
auto-aria-autocomplete
auto-aria-expanded
auto-aria-controls
auto-aria-activedescendant
/>
</x-input>
<x-button
aria-labeledby="cb1-label"
tabindex="-1"
aria-expanded="true"
aria-controls="cb1-listbox"
>
#shadow-root delegates="aria-expanded aria-controls aria-label"
<button auto-aria-expanded auto-aria-controls auto-aria-label></button>
</x-button>
</x-input-group>
<x-listbox
aria-labeledby="cb1-label"
id="cb1-listbox"
options='["Alabama", "Alaska"]'
>
#shadow-root delegates="label" reflects="aria-activedescendant role"
<ul role="listbox" reflect-role autolabel>
<li role="option">Alabama</li>
<li role="option" reflect-aria-activedescendant>Alaska</li>
</ul>
</x-listbox>
</x-combobox>
As we begin to see the benefits and capabilities that the Delegation and Reflection APIs open for out custom element architectures, this example can be further simplified.
<x-label for="cb1">
#shadow-root delegates="for"
<label autofor><slot></slot></label>
State
</x-label>
<x-combobox
id="cb1"
options='["Alabama", "Alaska"]'
>
#shadow-root delegates="focus label"
<x-input
aria-labeledby="cb1-label"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="listbox"
aria-activedescendant="listbox"
>
#shadow-root delegates="aria-labeledby role aria-autocomplete aria-expanded aria-controls aria-activedescendant"
<input
type="text"
auto-role
auto-aria-labeledby
auto-aria-autocomplete
auto-aria-expanded
auto-aria-controls
auto-aria-activedescendant
/>
</x-input>
<x-button
autolabel
tabindex="-1"
aria-expanded="true"
aria-controls="listbox"
>
#shadow-root reflects="role"
<button reflect-role></button>
</x-button>
<x-listbox
autolabel
id="listbox"
options=options
>
#shadow-root delegates="label" reflects="aria-activedescendant role"
<ul role="listbox" reflect-role autolabel>
<li role="option">Alabama</li>
<li role="option" reflect-aria-activedescendant>Alaska</li>
</ul>
</x-listbox>
</x-combobox>
In the above example, the move to leveraging a shadow root on <x-combobox>
even opened the one
to many relationship of the autolabel
delegation allowing for the return to the for
attribute,
which is delegated to an actual <label>
element to surface the interaction relationships we had
previously lost with the move to aria-labelledby
. All the while, we've reduced the DOM that a
consumer of this pattern is required to write to:
<x-label for="cb1">State</x-label>
<x-combobox
id="cb1"
options='["Alabama", "Alaska"]'
></x-combobox>
This feels like a really nice refactor. All of these changes are powered by highly useful API in the form of ARIA Attribute Delegation and ARIA Attribute Reflection and were previously not possible when placing shadow boundaries between important content in an interface. However, it does assume a greenfield implementation. A more realistic look at what these APIs can surface will be derived from decorating or composing existing patterns into these complex interfaces.
Take this interpretation of a popular custom elements library's "input" and "list" components:
<y-textfield
label="State"
>
#shadow-root
<label>
${label}
<input />
</label>
</y-textfield>
<y-button></y-button>
<y-list>
<y-list-item>Alabama</y-list-item>
<y-list-item>Alaska</y-list-item>
</y-list>
Let's see how we might be able to update this example with the ARIA Delegation and Reflection APIs in order to complete the Combobox contract for screen readers.
<y-textfield
label="State"
id="cb2-textfield"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="cb2-listbox"
aria-activedescendant="lb2-ak"
>
#shadow-root delegates="role aria-autocomplete aria-expanded aria-controls aria-activedescendant" reflects="label"
<label reflects-label>
${label}
<input
auto-role
auto-aria-labeledby
auto-aria-autocomplete
auto-aria-expanded
auto-aria-controls
auto-aria-activedescendant
/>
</label>
</y-textfield>
<y-button
aria-labelledby="cb2-textfield"
aria-expanded="true"
aria-controls="cb2-listbox"
icon="expand_more"
>
#shadow-root delegates="label aria-expanded aria-controls"
<button auto-label auto-aria-expanded auto-aria-controls>
<y-icon>expand_more</y-icon>
</button>
</y-button>
<y-list
aria-labelledby="cb2-textfield"
id="cb2-listbox"
>
<y-list-item id="lb2-al">Alabama</y-list-item>
<y-list-item id="lb2-ak">Alaska</y-list-item>
</y-list>
This places a pretty high burden on consumers:
<y-textfield
label="State"
id="cb2-textfield"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="cb2-listbox"
aria-activedescendant="lb2-ak"
></y-textfield>
<y-button
aria-labelledby="cb2-textfield"
aria-expanded="true"
aria-controls="cb2-listbox"
icon="expand_more"
></y-button>
<y-list
aria-labelledby="cb2-textfield"
id="cb2-listbox"
>
<y-list-item id="lb2-al">Alabama</y-list-item>
<y-list-item id="lb2-ak">Alaska</y-list-item>
</y-list>
However, it could be easily composed into a single shadow root:
<y-combobox
label="state"
aria-activedescendant="lb2-ak"
>
#shadow-root delegates="aria-activedescendant"
<y-textfield
label=label
id="cb2-textfield"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="cb2-listbox"
auto-aria-activedescendant
></y-textfield>
<y-button
aria-labelledby="cb2-textfield"
aria-expanded="true"
aria-controls="cb2-listbox"
icon="expand_more"
></y-button>
<y-list
aria-labelledby="cb2-textfield"
id="cb2-listbox"
>
<slot name="items"></slot>
</y-list>
<y-list-item id="lb2-al" slot="items">Alabama</y-list-item>
<y-list-item id="lb2-ak" slot="items">Alaska</y-list-item>
</y-combobox>
This comes out to the following for a consuming developer:
<y-combobox
label="state"
>
<y-list-item id="lb2-al" slot="items">Alabama</y-list-item>
<y-list-item id="lb2-ak" slot="items">Alaska</y-list-item>
</y-combobox>
Even less if you choose to encapsulate DOM management for the list items by accepting an array of
items the way our <x-combobox>
did above. However, in both cases the ARIA Attribute Delegation
and Reflection APIs are making it possible for more complex interfaces to be accessible when built
with custom element and shadow DOM without needing to architect your whole implementation around
the intricacies of keeping ID references in a single DOM tree.
There are many patterns where the presence of a shadow boundary will prevent otherwise default relationships between DOM element. Another that this API could directly benefit is a button group that manages selection on those buttons similar to what we see in a collection of radio buttons (one selected button) or checkboxes (multiple selected buttons).
Generally, this contract is made by having a role="radiogroup"
or role="group"
parent gather a
collection of role="radio"
or role="checkbox"
elements, respectively. In this case the native
semantics and focusability of a <button>
element can do a lot of the heavy lifting, but if you do
do inside of a shadow boundary you run into some problems:
<z-button-group role="radiogroup">
<z-button role="radio">
#shadow-root
<button><slot></slot></button>
Option 1
</z-button>
<z-button role="radio">
#shadow-root
<button><slot></slot></button>
Option 2
</z-button>
</z-button-group>
Here the role="radio"
elements share a DOM tree with the role="radiogroup"
elements to fulfill
the contract requried to build the correct accessibility tree and pass it on to screen readers.
However, the <button>
elements internal to the <z-button>
custom elements also take a place in
the accessibility tree confusing the pattern. Often custom element developers will either need to
pass the role
attribute synthetically, which means the "radio" elements is no longer in the same
DOM tree and the "radiogroup" element, or forgo the native <button>
element and the benefits of
leveraging it directly, like native tabindex
management, etc. Here is another useful place for
us to instead do a combination of delegating and reflecting aria attribtues from the shadow DOM into
the parent DOM tree:
<z-button-group role="radiogroup">
<z-button role="radio">
#shadow-root delegates="focus role" reflects="role"
<button auto-role reflect-role><slot></slot></button>
Option 1
</z-button>
<z-button role="radio">
#shadow-root delegates="focus role" reflects="role"
<button auto-role reflect-role><slot></slot></button>
Option 2
</z-button>
</z-button-group>
In this way, while somewhat convoluted, the <z-button>
elements will appear as a "radio" element
in the DOM tree of the <z-button-group>
element, fulfilling the accessibility tree contract, while the
<button>
will be the actual element that responsibility allow for native surfacing of other
accessible semantics, behaviors, etc.
The Aria Reflection API has already made a name for itself in the Aria community for the new capabilities that it unlocks. There is a possibility that an ARIA Attribute Reflection API would be confusing when places next to it. With that in mind, possible alternative may include:
- ARIA Attribute Export API
- This would update
reflects="..."
toexports="..."
andreflect-*
toexport-*
, etc.
- This would update
- ARIA Attribute Hoisting API
- This would update
reflects="..."
tohoists="..."
andreflect-*
tohoist-*
, etc.
- This would update
- ARIA Attribute Surfacing API
- This would update
reflects="..."
tosurfaces="..."
andreflect-*
tosurface-*
, etc.
- This would update
- ARIA Attribute Maping API
- Possibly becomes a parent API naming to include both this and the delegation API
- This doesn't give a specific direction for attribute naming, but might be worth concidering at large.
- Is it just an "Attribute" Mapping API, despecializing it for ARIA...
Not all cross-shadow use cases are covered. Cases like radio groups, tab groups, or combobox might require complexity that is not available yet at this current cross-root delegation API. Though custom versions of this might be possible where they weren't before, like building a custom radio group with the <input>
inside of a shadow root:
<div role="radiogroup">
<x-radio>
<template shadowroot="open" reflects="role aria-checked">
<input type="radio" reflectrole reflectariachecked />
</template>
</x-radio>
</div>
The reflection API might also not resolve attributions from multiple shadow roots in parallel or attributes that would point to DOM trees containing the current host component.
The attributes names such as shadowrootreflectss*
are very long and some consideration for shorter names by removing the shadowroot
prefix can be discussed as long the discussion is sync'ed with the stakeholders of the respective Declarative Shadow DOM proposal. This can be further shortened by taking the reflects-attributes
collection as a DOM token list on a single attribue.
- Can the various spec bodies come into agreement that using
-
in the attribute names will make them easier to spell/use.reflect-*
instead ofreflect*
andreflect-aria-autocomplete
instead ofreflectariaautocomplete
, etc.? - Could this be paired with a source element attribute that could surface the concepts that an single element in the shadow root reflects as a group. That way in the following example
reflect-attributes="role aria-checked"
could be leveraged instead of bothreflectrole
andreflect-aria-checked
:<div role="radiogroup"> <x-radio> <template shadowroot="open" reflects-attributes="role aria-checked"> <input type="radio" reflect-attributes="role aria-checked" /> </template> </x-radio> </div>
Leveraging the datalist
attribute on an encapsulated <input>
element requires that support for this feature is built into the same DOM tree. Support for reflection of this attribute would free this feature to be leveraged more widely:
<label for="ice-cream-choice">Choose a flavor:</label>
<d-input list="ice-cream-flavors" id="ice-cream-choice">
<template shadowroot="open" delegates="list label">
<input auto-list auto-label name="ice-cream-choice" />
</template>
</d-input>
<d-datalist id="ice-cream-flavors">
<template shadowroot="open" reflects="list">
<datalist reflect-list>
<option value="Chocolate">
<option value="Coconut">
<option value="Mint">
<option value="Strawberry">
<option value="Vanilla">
</datalist>
</template>
</d-datalist>
The OpenUICG is developing an API to support content that popsup over a page. In thier example of
using the new attributes in shadow DOM
the element owning the popup
attribute is encapsulated in a shadow root to protect it from the
surrounding application context. This means that the relationship required for a togglepopup
,
showpopup
, or hidepopup
bearing element can no longer be made from that level:
<my-tooltip>
<template shadowroot=closed>
<div popup=hint>This is a tooltip: <slot></slot></div>
</template>
Tooltip text here!
</my-tooltip>
This could be addressed by reflecting the popup
element to the host like so:
<button togglepopup=foo>Toggle the pop-up</button>
<my-tooltip id="foo">
<template shadowroot=closed reflects="popup">
<div popup=hint reflect-popup>This is a tooltip: <slot></slot></div>
</template>
Tooltip text here!
</my-tooltip>
Here, the declarative relationship between the [togglepopup]
element and the [popup]
elements
can be made without surfacing the .showPopUp()
method directly on the <my-tooltip>
container.
https://w3c.github.io/webcomponents-cg/#cross-root-aria
GitHub Issue(s):