Table of Contents generated with DocToc
- ARIA (and other) relationship attributes and Shadow DOM
- Problems
- Proposed solutions
- Some thoughts on potential refinements/alternative directions
@nolanlawson has a longer write-up of this issue here: https://gist.github.com/nolanlawson/4fe8b5d672cda3bcc4daf58079145202
It is possible to refer from an element within a shadow root to any element which is a descendant of any ancestor shadow root or document, using an IDL attribute which takes an Element
or FrozenArray<Element>
.
<custom-listbox>
#shadowRoot
| <div id="listbox" role="listbox"
| aria-activedescendant>
| <slot></slot>
#/shadowRoot
<custom-listitem>Author listitem 2</custom-listitem>
<custom-listitem>Author listitem 3</custom-listitem>
<custom-listitem>Author listitem 4</custom-listitem>
</custom-listbox>
// refers to an element in a "lighter" tree
listbox.ariaActiveDescendantElement = slot.assignedNodes()[0];
This doesn't work at all for referring into an element within a shadow root which is not a direct ancestor.
<my-label>
#shadowRoot
| <!-- wants to refer into custom-combobox's shadow root -->
| <label for="⚠️inner-input⚠️"><slot></slot></label>
#/shadowRoot
Postal address:
</my-label>
<custom-combobox>
#shadowRoot
| <!-- wants to refer into custom-optionlist's shadow root -->
| <input role="combobox" id="inner-input"
| aria-owns="optlist"
| aria-activedescendant="⚠️opt1⚠️">
| <custom-optionlist id="optlist">
| #shadowRoot
| | <x-option id="opt1">221B Baker St</x-option>
| | <x-option id="opt2">29 Acacia Road</x-option>
| | <x-option id="opt3">724 Evergreen Terrace</x-option>
| #/shadowRoot
| </custom-optionlist>
#/shadowRoot
</custom-combobox>
It isn't even possible to refer to an element within an element's own shadow root.
Recall the earlier example of creating a reference from within a shadow root out to an ancestor subtree:
<custom-listbox>
#shadowRoot
| <div id="listbox" role="listbox"
| aria-activedescendant>
| <slot></slot>
#/shadowRoot
<custom-listitem id="item1">Author listitem 2</custom-listitem>
<custom-listitem id="item2">Author listitem 3</custom-listitem>
<custom-listitem id="item3">Author listitem 4</custom-listitem>
</custom-listbox>
// refers to an element in a "lighter" tree
listbox.ariaActiveDescendantElement = slot.assignedNodes()[0];
This does work, but can't ever be serialized, because the ID of the listbox's aria-activedescendant-associated element, "item1", isn't in the same tree as the listbox.
Declarative shadow DOM currently doesn't support initializing multiple elements from the same declarative shadow root <template>
, due to unrelated technical limitations.
If this did become possible, it might be necessary to refer to the source <template>
via an IDREF type association. This may need to work across an arbitrary number of shadow root boundaries.
(Is this at all a potential issue? Someone who knows more about declarative shadow DOM than me can hopefully verify.)
3. Custom element which wrap semantic elements should be able to honour author attributes without authors needing to know about their internals
<!-- ideally this should create a full label
association with the inner <input> -->
<label for="name-input">Given name: </label>
<fancy-input id="name-input"
aria-describedby="description">
#shadowRoot
| <input/>
| <span>Fancy!</span>
/#shadowRoot
</fancy-input>
<!-- this should be linked from the inner <input>,
not the <fancy-input> -->
<div id="description">Also called your first name.</div>
The custom element author could write code to move ARIA attributes to the wrapped semantic element:
// in the class definition for FancyInput
attributeChangedCallback(name, oldValue, newValue) {
if (name === "aria-describedby") {
let describedByElements = newValue.split(" ").map((id) => {
this.getRootElement().getElementById(id);
});
input.ariaDescribedByElements = describedByElements;
}
// and one of these for each attribute :(
}
Obviously, this is both repetitious and error-prone.
As well as being not very good, this also doesn't address the case where an element outside the shadow root needs to be able to refer into it without knowing it exists, as in the label/for case.
Depending on why the element is wrapped and what it is, it may someday be possible to use customized built-in elements instead of wrapping a semantic element.
This may never eventuate, and doesn't work for any cases where the semantic element is accompanied by more content inside the shadow root (which presumably is the vast majority of cases?)
@rniwa proposed aria-maps
, later proposed as exportids
/importids
, in WICG/aom#169.
This would allow someone using a custom element to specify how to map IDs inside the shadow root to IDs in the context the custom element is used in.
This is based on the idea behind exportparts
, which allows an element inside a shadow root to export a part
inside its shadow root as if it was a part
of its shadow host.
<outer-element>
#shadowRoot
| <middle-element exportparts="inner">
| #shadowRoot
| | <inner-element part="inner">hello</inner-element>
| </middle-element>
</outer-element>
<style>
outer-element::part(inner) {
color: tomato; // matches the <inner-element>
}
</style>
Conversely, exportids
/importids
appears on a shadow host in any context, and (in the case of exportids
) makes an ID (potentially re-mapped to a different value) available for targeting in the context of the shadow host.
<!-- outer-element also needs to export the ID
for it to be available to sibling-element -->
<outer-element exportids="inner">
#shadowRoot
| <middle-element exportids="inner">
| #shadowRoot
| | <inner-element id="inner">hello</inner-element>
| </middle-element>
</outer-element>
<sibling-element aria-labelledby="inner"></sibling-element>
It also has other issues:
- it doesn't solve problem #3: it forces page authors to have some knowledge of the element's internals, even if it's as minimal as knowing that the element wraps a semantic element with a given ID;
- unlike
part
, IDs are not an explicit opt-in to being exposed as part of a public API - also unlike
part
, IDs are meant to uniquely refer to one element in a given context, so the burden would fall back on the author using the custom element to map IDs to an ID that is unique in its context; - this process will need to be repeated as many times as there are shadow roots between one element and another element it needs to be attr-associated with;
- it's unclear what ID-related APIs will work with exported IDs - it should only be attr-association, probably? What about CSS ID-based matching?
getElementById
? etc.
This was proposed by @nolanlawson in WICG/aom#192.
This partly solves problem #1, but not #2 or #3. Partly, because it only solves the problem for open shadow roots, not closed shadow roots. This means component authors would be forced to use open shadow roots regardless of their preferences, if they wished to be able to use the HTML/DOM APIs which use IDREFs/element references.
It also had push-back around failing to preserve the encapsulation guarantees of shadow DOM, even given those guarantees are somewhat weaker with open shadow roots. @jcsteh said:
[Even the more porous boundaries of open shadow roots are] clearly defined with regard to leaking element references. That is, you need to explicitly choose to dive into the shadowRoot to access elements inside it; it doesn't just happen as a side effect of some other API. The problem with ARIA element reflection is that this boundary crossing would not at all be explicit.
One option mentioned in WICG/aom#192 was to allow setting a reference to elements within shadow roots, but not to return that element from the el.attrElement[s]
(e.g. el.ariaActiveDescendantElement
) getter.
This was dismissed as having poor ergonomics, since it would be impossible to understand which element had been set.
I don't know/remember whether anything like the proposals below was discussed. That is, something like:
innerInput.activeDescendantElement = opt3;
console.log(innerInput.activeDescendantElement); // logs <custom-optionlist id="optlist">
input.getAttrAssociatedElement("aria-activedescendant",
{shadowRoots: [xOptListShadowRoot]});
// logs <x-option id="opt3">
These complementary proposals (Cross-root ARIA Delegation and Cross-root ARIA Reflection) add attributes which allow a custom element:
- to "delegate" ARIA attributes from a shadow host to a single element (per attribute) with the shadow root, and
- to "reflect" (export) a particular element (or several?) within a shadow root as the target of a relationship from an element outside the shadow root.
The exact syntax being proposed seems to be heavily in flux, but the general shape of the API is inspired by delegates focus.
delegatesFocus on a shadow root causes the shadow host not to be a focusable area itself, but "transparent" for the purposes of focus.
A shadow host, like a document, is always a focus navigation scope owner. When building the sequential focus navigation order (i.e. the "tab order") for a focus navigation scope owner (starting with the document), the process involves recursively:
- building a tabindex-ordered focus navigation scope, consisting of the collection of focusable areas and focus navigation scope owners, ordered according to the rules for the
tabindex
attribute - for each focus navigation scope owner (i.e. each shadow host), either replace the owner with its internal tabindex-ordered navigation scope, if it delegates focus (i.e. focus skips the shadow host and moves directly to the first focusable element in its shadow-including descendants), or append it after the owner if it doesn't (i.e. focus lands first on the shadow host, then the focusable elements in it shadow-including descendants).
So, delegatesFocus
ensures that using tabindex
on shadow hosts doesn't cause them to be incorrectly treated as focusable areas if they're not intended as such, but tabindex
on shadow hosts is otherwise respected for the purposes of constructing the tab order for a document (including `tabindex="-1" to remove an element from the tab order).
Additionally, delegatesFocus
causes a kind of "passthrough" effect for the autofocus
attribute. If autofocus
causes the get the focusable area algorithm to run on a shadow host with delegatesFocus=true
(e.g. when a shadow host with an autofocus
attribute and delegatesFocus=true
on is shadow root is inserted into the document), it will look for a focus delegate within the shadow root, beginning with looking for an autofocus delegate. This is also recursive, so this will continue through as many layers of shadow roots as necessary to find an element with autofocus
which doesn't delegate focus, or a focus navigation scope which doesn't have an element with autofocus
, so that the first focusable element is returned.
This has been written up by @leobalter and @mrego here: https://github.com/leobalter/cross-root-aria-delegation/blob/main/explainer.md.
So, taking inspiration from this idea, cross-root ARIA delegation would allow a shadow root to declare that it "delegates" certain attributes from the shadow host to specific elements within the shadow root.
Taking a modified example from the explainer which uses declarative shadow DOM as an illustration:
<span id="description">Description</span>
<x-input aria-label="Name" aria-describedby="description">
<template shadowroot="closed"
shadowrootdelegatesariaattributes="aria-label aria-describedby">
<input id="input"
delegatedariaattributes="aria-label aria-describedby" />
<button delegatedariaattributes="aria-label">Another target</button>
</template>
</x-input>
This example would result in both the <input>
and the <button>
having the aria-label
from the shadow host ("Name") applied, and the <input>
having an aria-describedby
relationship with the span adjacent to the <x-input>
.
This means that an author using the <x-input>
element can simply apply ARIA attributes to the shadow host exactly as they would for a regular <input>
element, and have them work as expected.
Furthermore, like delegatesFocus
, this proposal would work recursively: any number of levels of shadow roots could delegate attributes from a shadow host to the desired target for those attributes.
This does offer at least a partial solution to problems 2 and 3 enumerated above:
- For a subset of cases where a relationship between an element within shadow DOM and an element in an ancestor's tree need to be serialised, the shadow host can
delegate
the appropriate attribute to the element in shadow DOM (recursively if necessary). - Author attributes on custom elements can be respected, assuming the custom element author has correctly anticipated which attributes should apply to which internal elements.
Cross-root ARIA reflection is complementary to cross-root ARIA delegation. Essentially, it allows a custom element author to "export" elements inside of a shadow root to be available as a target for relationship attributes.
Modifying an example from the explainer, once again using declarative shadow DOM:
<input aria-controls="options" aria-activedescendent="options">
<x-optlist id="options">
<template shadowroot="open"
shadowrootreflectsariacontrols
shadowrootreflectsariaactivedescendent>
<ul reflectariacontrols>
<x-option id="opt1">221B Baker St</x-option>
<x-option id="opt2" reflectariaactivedescendant>29 Acacia Road</x-option>
<x-option id="opt3">724 Evergreen Terrace</x-option>
</ul>
</template>
</x-foo>
The <input>
ends up with an aria-controls
relationship with the <ul>
, and an aria-activedescendant
relationship with the second <li>
.
This is complementary to cross-root ARIA delegation in that it allows the creation of relationships in the opposite direction: from outside a shadow root to inside.
The example given in the explanation of problem #3 is quite a straightforward "wrapping" of an <input>
element inside a shadow root: for the purposes of the author using the <fancy-input>
element, it IS AN <input>
, and should behave like one when using a <label>
, for example. This makes it a good semantic fit for the cross-root ARIA delegation proposal: it's effectively setting up that IS A relationship by delegating certain properties to the <input>
.
Note: I use the term "wrapping" to indicate a custom element which "bundles" an element inside its shadow root, so that an author using the custom element can simply use it, like
<fancy-input>
. Some custom elements use a "decorating" pattern instead, where an author has to "pass in" one or more elements to be decorated/enhanced as part of the custom element's API. This is how<iron-input>
works.My usage of these terms isn't from any kind of agreed-upon standard; this is just my idiosyncratic terminology. If there is agreed-upon terminology, I will gladly update my vocabulary and this doc!
The above <x-optlist>
example, conversely, involves an <input>
referring into a child of its sibling <x-optlist>
's shadow root for the purposes of creating an aria-activedescendant
relationship. There is no reasonable workaround possible for this use case: aria-activedescendant
indicates the selected autocomplete option for the <input>
while focus is on the input. In this case, we imagine the autocomplete options are being dynamically updated from a potentially huge list of options (i.e. all known addresses), and the authors have deliberately chosen to encapsulate the options within a shadow root in order to avoid any implementation details of how those options are managed from impacting the surrounding DOM.
In this case, it's not really true that the author using the <x-optlist>
wants to remain completely ignorant of its internals. They need to "know" which option is active, in the sense that they need to create an aria-activedescendant
relationship with the <input>
. Creating an aria-activedescendant
relationship with the <x-optlist>
will have the correct outcome as far as the accessibility tree is concerned, but semantically it looks odd - the activedescendant is the <x-option>
, not the <x-optlist>
.
Conversely, the aria-controls
relationship is an example of an IS A type relationship - the <ul>
semantically IS the actual option list.
The main limitation of these APIs is that they can only allow expressing relationships to one element or set of elements for each attribute in each direction. Effectively, it makes the shadow root a bottleneck for these types of relationships.
For example, a shadow host which has an aria-describedby
value can create a relationship from any number of elements within its shadow root (and their shadow roots, recursively) to a single list of elements in its tree. If there are multiple elements within its shadow root which all need different cross-root aria-describedby
values, these APIs can't achieve that.
Similarly, a shadow host may reflect
an element (or several?) as a target for a particular attribute, but any element referring to the shadow host for that relationship will create a relationship with the same element(s).
<custom-address id="address">
#shadowRoot
| <div>
| <slot name="labelforstreet">
⤷ <label slot="labelforstreet" for="?????">Street</label>
| </slot>
| <input id="street" aria-describedby="?????">
| <slot name="descriptionforstreet">
⤷ <span slot="descriptionforstreet" id="streetdescription">
The street address
</span>
| </slot>
| </div>
| <div>
| <slot name="labelforsuburb">
⤷ <label slot="labelforsuburb" for="?????">Suburb</label>
| </slot>
| <input id="suburb" aria-describedby="?????">
| <slot name="descriptionforsuburb">
⤷ <span slot="descriptionforstreet" id="suburbdescription">
The suburb
</span>
| </slot>
| </div>
#/shadowRoot
<label slot="labelforstreet" for="?????">Street</label>
<span slot="descriptionforstreet" id="streetdescription">The street address</span>
<label slot="labelforsuburb" for="?????">Suburb</label>
<span slot="descriptionforstreet" id="suburbdescription">The suburb</span>
</custom-address>
In this extremely contrived and unlikely example, the two <input>
s inside the shadow root each want different <label>
s to be able to refer to them, and want to refer to different light tree elements using aria-describedby
. Since the delegation/reflection APIs only allow one target or set of targets for each attribute in each direction (inwards or outwards), this could not work with these APIs.
For the purposes of problem #3, this likely isn't an issue, because problem #3 at least partly implies that the custom element is being treated as a kind of "atomic" element (other than the complex component cases outlined above), such that it conceptually makes sense to apply ARIA attributes to it. However, for this reason, it isn't a general solution to problems #1 and #2.
Depending on the exact shape of the API, it may result in the addition of multiple IDL attributes per ARIA attribute to certain objects.
This may be fairly straightforwardly avoided, however. The proposal sketched for cross-root ARIA delegation use token list attributes, for example, which means only one attribute needs to be added (plus the extra attribute on <template>
/ShadowRoot
). Similarly, the proposed exportfor
attribute seems to be an analogous version of reflectaria*
which takes a token list.
(Thanks to Sarah Higley/@smhigley for pointing this out.)
aria-activedescendant
is an inherently dynamic attribute, and notifying the AT of changes in active descendant depend on the attribute actually changing.
We would have to be careful in specifying the AT mapping of delegated attributes that a change in which element declares itself to be the target for the delegated attribute is equivalent to the value of the delegated attribute being changed on the attribute's host element.
It seems to me like problem #3 has (at least) two iterations (which may both be present in the same custom element):
- An element inside of a shadow root is being used as the semantic "delegate" of the element - e.g. the role of that one element is interpreted as the role of the shadow host from the point of view of authors using the custom element. This is the
<fancy-input>
situation. This "semantic root" will not change over the lifetime of the element. - An element inside of a shadow root needs to be made available dynamically as a "part" for targeting by an API like
aria-activedescendant
(any other examples?). This is the<x-option>
situation.
Some examples of (1) from existing component libraries:
- FAST
<text-field>
wraps an<input>
- FAST
<disclosure>
wraps<details>
/<summary>
- FAST
<combobox>
which wraps an<input>
(but seems to also secretly allow decorating an<input>
, which blows my mind) - Polymer
<paper-input>
- interestingly, this wraps an<iron-input>
which decorates an<input>
- Spectrum
<sp-checkbox>
which wraps an<input type="checkbox">
- Spectrum
<sp-action-menu>
which wraps a<sp-action-button>
Each of these has a single element which acts as the "semantic delegate" of the custom element. That is, for each element, there is an element within its shadow root which performs the role an author would expect the custom element as a whole to play. (Even in the case of FAST <disclosure>
, the <details>
element acts as the "semantic root" - the <summary>
is part of the implementation of the disclosure pattern, but is subordinate to <details>
.)
I think a significant proportion (but certainly not all) of the use cases for cross-root ARIA delegation and cross-root ARIA reflection could be handled by an API which "simply" allows a custom element's author to denote a single element as its semantic delegate.
For illustrative purposes only, imagine an API something like:
<x-label for="x-input">
<template shadowrootsemanticdelegate="label">
<span>X</span> <!-- this would NOT be part of the label for the <input> -->
<label id="label"> <!-- labels the <input> inside <fancy-input> -->
<slot></slot>
</label>
</template>
Label
</x-label>
<x-input id="x-input" aria-describedby="description">
<template shadowrootsemanticdelegate="input">
<span>X</span>
<input id="input">
</template>
</x-input>
<!-- describes the <input> inside <fancy-input> -->
<span id="description">Description</span>
The upshot of marking a particular element as a shadow root's semantic delegate would be more or less the same as if it delegated all ARIA attributes (this might need an include list of attributes to delegate - would it be all and only ARIA attributes and IDREF-targeting attributes like for
?) and reflected the semantic delegate for the purposes of any IDREF attribute targeting.
So, in the above example, the <label>
within the <x-label>
would have a for
association with the <input>
within the <x-input>
, by virtue of the <label>
and <input>
being their respective shadow roots' semantic delegates - the for
on the <x-label>
would be delegated to the <label>
, and the <input>
would be "reflected for" the for
attribute, making it the target instead of the <x-input>
. Likewise, the <input>
would have an aria-describedby
association with the <span id="description">
.
None of this would require any extra attributes on the <input>
or the <label>
. And, naturally, an imperative version of shadowrootsemanticdelegate
(or whatever) would be necessary; the declarative shadow DOM version is just more concise to write an example for.
As I outlined above, it's not quite true that an author using a custom element can remain completely ignorant of its contents in the case where an aria-activedescendant
for an element is within a shadow root which is not an ancestor. If they want to refer into a shadow root, it's semantically odd to refer to the shadow host, even if technically the host has the ability to delegate the target of the attribute to the correct descendant.
One idea might be to poke the relevant descendant out into light DOM by having a kind of "roving <slot>
", which would make the "reverse slotted" element appear as a light DOM child, with no ability to tree walk back into its shadow DOM siblings. This is sort of like part
, and sort of like reflectattr
.
This is probably a very silly idea, but hopefully it might at least spark some good ones. As such, I don't even have any code examples for it, you'll just have to imagine it.
Two possible versions of this:
- Special GUIDs
- Like XPATH
@bkardell suggested a mechanism for making an ID globally available to IDREF attributes regardless of shadow roots.
This might take the form of a separate attribute, e.g. globalid
, which requires a decorator to be used as an IDREF. Just like with regular IDs, authors would need to explicitly set these global ID attributes and wire them up to the appropriate IDREF attribute.
<my-label>
#shadowRoot
| <label for="globalid(36ac)"><slot></slot></label>
#/shadowRoot
Postal address:
</my-label>
<custom-combobox id="addresses">
#shadowRoot
| <!-- wants to refer into custom-optionlist's shadow root -->
| <input role="combobox" id="inner-input"
| globalid="36ac"
| aria-owns="optlist"
| aria-activedescendant="globalid(4a62)">
| <custom-optionlist id="optlist">
| #shadowRoot
| | <x-option id="opt1" globalid="4a62">221B Baker St</x-option>
| | <x-option id="opt2">29 Acacia Road</x-option>
| | <x-option id="opt3">724 Evergreen Terrace</x-option>
| #/shadowRoot
| </custom-optionlist>
#/shadowRoot
</custom-combobox>
Alternatively, you could have some kind of known separator like ":" to indicate traversing into an element's shadow root, and some keywords like "super" (or "host" or whatever) and "root" to indicate traversing up or starting from the document root.
<label for="x-input:y-input:input"></label>
<template clonefrom="root:my-template"></template>
<input aria-describedby="super:description">
This would lead to the occasional ID collision, as inevitably some code will exist with IDs which include the separator string, but normal ID collision precedence rules could be used (probably with an extra rule to prefer not crossing any shadow roots).
For IDL attributes which are computed from content attributes using any of these shadow root crossing ID options, some mechanism to ensure they don't break encapsulation would be necessary.
One option might be to allow the IDL attribute getter for the relevant content attribute to return the closest shadow host which is a descendant of a shadow-including ancestor.
For example, to return to the "postal address" example, using the "XPATH-style" API for the sake of illustration:
<my-label>
#shadowRoot
| <!-- wants to refer into custom-combobox's shadow root -->
| <label for="super:addresses:inner-input"><slot></slot></label>
#/shadowRoot
Postal address:
</my-label>
<custom-combobox id="addresses">
#shadowRoot
| <!-- wants to refer into custom-optionlist's shadow root -->
| <input role="combobox" id="inner-input"
| aria-owns="optlist"
| aria-activedescendant="optlist:opt1">
| <custom-optionlist id="optlist">
| #shadowRoot
| | <x-option id="opt1">221B Baker St</x-option>
| | <x-option id="opt2">29 Acacia Road</x-option>
| | <x-option id="opt3">724 Evergreen Terrace</x-option>
| #/shadowRoot
| </custom-optionlist>
#/shadowRoot
</custom-combobox>
// ... assume sensible initialisation of variables has happened ...
console.log(label.forElement); // logs <custom-combobox>
console.log(input.ariaOwnsElements); // logs [<custom-optionlist>]
console.log(input.ariaActiveDescendantElement); // logs <custom-optionlist>
Optionally, there might be scope for an API like the following, to allow retrieving elements when a shadow root is already "known".
// ... again, assuming sensible variable initialisation ...
input.getAttrAssociatedElement("aria-activedescendant",
{shadowRoots: [xOptListShadowRoot]});
// logs <x-option id="opt3">
Roughly, the polyfill provides lower-bound CSS scoping but not upper-bound (similar to the "open stylable" proposal actually), plus
<slot>
s, plus shadow DOM semantics in JavaScript (e.g.querySelectorAll
,getRootNode
, etc.), plus some other stuff (e.g. very lightweight ID scoping, which has loopholes, hence the current ARIA dilemma 🙂).I would not block the proposal on the bottleneck issue – it's too useful just to get anything that works. But I think it would be good to at least have a sketch of how to solve the bottleneck issue in the future.