AOM descendant/ancestor restriction problem explainer
Summary
This is an overview of WICG/aom #192 and why it's a problem for web component authors using shadow DOM.
Consider the combobox
Let's consider the classic combobox pattern from the WAI ARIA Authoring Practices v1.2:
Here's the HTML (some details have been omitted):
<div>
<div>
<input type="text" role="combobox"
aria-autocomplete="list" aria-expanded="true"
aria-controls="cb1-listbox"
aria-activedescendant="lb1-wi">
</div>
<ul id="cb1-listbox" role="listbox" aria-label="States">
<li id="lb1-wa" role="option">Washington</li>
<li id="lb1-wv" role="option">West Virginia</li>
<li id="lb1-wi" role="option" aria-selected="true">Wisconsin</li>
<li id="lb1-wy" role="option">Wyoming</li>
</ul>
</div>
Note the relationships:
<input>
hasaria-controls
pointing to the<ul>
.<input>
hasaria-activedescendant
pointing to the third<li>
.
Enter shadow DOM
Now let's write the same thing, but using standard web component practices, including shadow DOM:
<fancy-input>
#shadow-root
<input type="text">
</fancy-input>
<fancy-listbox>
#shadow-root
<ul role="listbox">
<fancy-option>
#shadow-root
<li role="option">Washington</li>
</fancy-option>
<fancy-option>
#shadow-root
<li role="option">West Virginia</li>
</fancy-option>
<fancy-option>
#shadow-root
<li role="option">Wisconsin</li>
</fancy-option>
<fancy-option>
#shadow-root
<li role="option">Wyoming</li>
</fancy-option>
</ul>
</fancy-listbox>
Using AOM element reflection, we would like to express the aria-activedescendant
relationship:
input.ariaActiveDescendantElement = li3
However, this doesn't work. After running this code, the ariaActiveDescendantElement
will be null
:
console.log(input.ariaActiveDescendantElement) // null
Why? Because li3
is not a descendant of any of the input's shadow-including ancestors:
If
element
's explicitly setattr
-element is a descendant of any ofelement
's shadow-including ancestors, then returnelement
's explicitly set attr-element. Otherwise, return null.
Why the restriction?
Why does this restriction exist? The main goal is to preserve encapsulation, especially for closed shadow roots. Consider this expando:
element1.customExpando = element2
In this case, anyone with access to element1
now has access to element2
:
console.log(element1.customExpando) // element2
Without this expando, the only way to access element2
from element1
would be if element2
was in the same shadow root,
or in an ancestor shadow root. This is because, even in closed shadow DOM, you can always walk up
the tree as much as you want:
element1
.getRootNode().host
.getRootNode().host
.getRootNode().host // etc
... but you cannot walk down the tree:
[...element1.getRootNode().querySelectorAll('*')]
.map(_ => _.shadowRoot) // null for closed shadow
So the restriction on AOM element reflection preserves this same rule:
console.log(element.ariaActiveDescendantElement) // null if not in element's shadow-including ancestors
Why is the restriction a problem?
Let's go back to the combobox example above. To make the ariaActiveDescendantElement
relationship
work with AOM reflection, we would have to ensure that the <li>
is
in an ancestor shadow root of the <input>
. This requires changing the structure of the DOM,
and removing several shadow boundaries between
the <input>
and the <li>
:
<fancy-input>
#shadow-root
<input type="text">
</fancy-input>
<fancy-listbox>
<ul role="listbox">
<fancy-option>
<li role="option">Washington</li>
</fancy-option>
<fancy-option>
<li role="option">West Virginia</li>
</fancy-option>
<fancy-option>
<li role="option">Wisconsin</li>
</fancy-option>
<fancy-option>
<li role="option">Wyoming</li>
</fancy-option>
</ul>
</fancy-listbox>
In short, <fancy-listbox>
cannot have encapsulation from <fancy-input>
. Nor can the individual <fancy-option>
s have encapsulation from each other, or from the <fancy-listbox>
.
In effect, the developers of these components cannot use shadow DOM and must use light DOM as much as possible to minimize crossing shadow boundaries.
For developers of closed shadow root components, perhaps this restriction makes sense. But for developers of open shadow root components, this restriction does not match the existing semantics, since open shadow roots can be fully traversed in either direction.
Does cross-root ARIA delegation solve this?
No. Cross-root ARIA delegation allows
nodes to delegate their aria-*
attributes to their hosts.
In this case, the <input>
being able to delegate aria-activedescendant
to its host does not
help, because the host still only has access to the ID of the <li>
if both are in the same shadow root.
What is the proposed solution?
My proposal is to make AOM reflection follow the existing semantics for both open and closed shadow roots:
element1.ariaActiveDescendantElement = element2
Relationship between elements | Shadow mode | Allowed? |
---|---|---|
element1 and element2 are in the same shadow root |
Any | ✅ |
Any (sibling, cousin, ancestor, descendant, etc.) | All shadow roots separating element1 from element2 are open |
✅ |
element2 is in a shadow-ancestor of element1 |
Any shadow root separating element1 from element2 is closed |
✅ |
element2 is not in a shadow-ancestor of element1 |
Any shadow root separating element1 from element2 is closed |
❌ |
In this system, the restriction would still exist for closed shadow roots, but it would not exist for open shadow roots.