Skip to content

Instantly share code, notes, and snippets.

@alice
Last active April 7, 2023 00:55
Show Gist options
  • Save alice/54108d8037f865876702b07755f771a5 to your computer and use it in GitHub Desktop.
Save alice/54108d8037f865876702b07755f771a5 to your computer and use it in GitHub Desktop.

Table of Contents generated with DocToc

ARIA (and other) relationship attributes and Shadow DOM

Problems

1. Currently specced APIs offer no way to refer into shadow roots.

@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.

2. Using Element-based IDL attributes to refer across shadow roots isn't serializable

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.

A potential issue: declarative shadow DOM and reusable <template>s

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?)

Proposed solutions

exportids/importids fka aria-maps

@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.

Allow referring into open shadow roots

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.

Allow referring into open shadow roots without leaking references

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">

Cross-root ARIA delegation/reflection

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.

A quick recap of delegatesFocus

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:

  1. 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
  2. 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.

Cross-root ARIA delegation

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

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.

Complex components, aria-activedescendant and Shadow DOM

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.

Limitations of these APIs

Bottleneck effect

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.

Proliferation of IDL attributes

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.

Dynamic updates are opaque

(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.

Some thoughts on potential refinements/alternative directions

Reify the idea of a "semantic delegate"

It seems to me like problem #3 has (at least) two iterations (which may both be present in the same custom element):

  1. 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.
  2. 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:

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.

A "reverse slot" for aria-activedescendant (any other use cases?)

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.

Some kind of ID syntax to allow arbitrary cross shadow root ID references

Two possible versions of this:

  1. Special GUIDs
  2. Like XPATH

Special GUIDs

@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>

Like XPATH

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).

Avoiding breaking encapsulation

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">
@nolanlawson
Copy link

A potential issue: declarative shadow DOM and reusable <template>s

In our own efforts to ship Declarative Shadow DOM in production, we've run with the assumption (so far) that it's not a big problem. Gzip mitigates any duplication in HTML files, and from the browser's POV, it should be possible to optimize duplicate <template>s if need be. (Browsers already optimize duplicate <style>s in shadow roots.)

At that point, we're left with developer ergonomics/aesthetics. (I.e. repeating so much HTML is ugly.) But given that this also affects non-shadow-DOM using HTML (or just classes, in e.g. Tailwind or Bootstrap), it doesn't seem like a showstopper to me.

@alice
Copy link
Author

alice commented Feb 8, 2023

@nolanlawson Thanks so much for this clarification! Good to know it's not a likely issue.

@nolanlawson
Copy link

Reify the idea of a "semantic delegate"

Until today I hadn't grokked this section. IIUC you are proposing a "simplified" version of cross-root ARIA delegation/reflection that effectively defines a subset of that API, with a much terser shorthand for referencing all possible aria* attributes?

AIUI, this solves both the "delegation" and "reflection" cases (using the terminology from that spec), but only when there is a 1-to-1 mapping between elements inside and outside the shadow root. So it works for the <x-input>/<x-label> case, but not the combobox or "component with two <input>s" case.

The main upside I see with this proposal is that it's a lot less typing, and a component author doesn't have to know in advance which aria* attributes they want to expose. That seems like great developer ergonomics, and it's also a reasonable stepping stone if it's just a shorthand for what you can do with the other spec.

Neither spec, though, seems to solve the "bottleneck" problem. But maybe this can be solved by the "roving slot" idea. I do like the idea of using something akin to parts here – you're right that it's odd, in the aria-activedescendant case, to point to the host when you really want one of the <option>s inside of it. And in the case of the "two <input>s" component, a part feels really natural for giving a name to each <input>.

@Westbrook
Copy link

Really nice introduction to this problem space!

I agree that "declarative shadow DOM and reusable <template>s" likely isn't a problem. I'd never delved the technical reasoning that Nolan has, but from a practical standpoint, you can't share a <template> that contains state with the currently available DOM APIs. Maybe something like DOM Parts will change that in the future, but let's not wait for that to get this into the platform!

With native browser elements, I'd also agree that relating an <input> to an <x-listbox> via aria-activedescendent might "semantically look odd", but I'd propose that is a vestigial reality of native browser elements only ever having an "is a ..." definitions while the expansion of the DOM language provided by Custom Elements and Shadow DOM opens the door to "has a ..." or possibly more appropriate "provides a ..." realities that would be better to embrace as early as we can. "Provides a ..." feels like the perfect definition of a "wrapper" element, while being the reciprocal of a "is provided a ..." which matches a "decorator" element. I'd 100% use the same language here, and suggest that it's an important perspective in this space as it's easier to get close to something accessible (or even get all the way there) when an element "is provided a ...", but it heaps often unexpected responsibilities on a consuming developer, while an element that "provides a ..." must be given new capabilities via the style of APIs discussed herein to do the same "out of the box" for developers, even as it puts new responsibilities of browsers themselves.

I'm not sure that the bottle neck effect is a bad thing. While I've just proposed that "provides a ..." is an important part of the expansion of possibilities that Custom Elements and Shadow DOM open for developers, I still feel that the interface being an element points to the contents only being one of any of the many things it could be. So while an <x-listbox> could be a listbox and provide an aria-activedescendent, I'm not sure that it's a good idea to propose support for an <x-listboxes> pattern that provided multiple listboxes, or multiple aria-activedescendents. I'm sure someone would come along to contradict me, but I feel that most approach to this would end up being "extremely contrived and unlikely example"s, ones that likely lead to more trouble for a developer or consumer of an element that good. However, I might solve your contrived example via a different DOM structure: https://codepen.io/Westbrook/pen/YzjmjKe

I really like the closer parallel that you are proposing here between aria and focus. However, I'm not sure that it makes sense to restrict the delegation process to a single element. As the author of one third of your example elements here, I can saw with confidence that the two most important factors in architecting those elements as they are were a) the APIs currently available directing the implementations in a specific direction and b) them existing within a design system and not an application system. A design system is much more likely to feature the one to one relationship outlined by shadowrootsemanticdelegate, however I do not believe that to outline the whole of the Custom Element development ecosystem. For every one to one element implementation you're likely to see you <custom-combobox> from above or an <x-addresses-list> element that manages the data fetching you referenced for the list of all addresses in the world that composes an <x-listbox> and <x-option> elements within it so that sometimes it can be leveraged as part of a "select" UI, other as part of a <datalist>, and yet more as a full on "combobox".

I'd like to stand against the idea of a reverse <slot> or special GUIDs as steadfastly as possible. The implementation detail leakage that it implies greatly endangers the refactorability of having your DOM and JS encapsulated within a Shadow Root. The only thing that a consumer or author should be worrying about in this area is whether something is provided by an element (consumer) and whether something needs to be applied form the outside (author), not how it come there or from where it comes.

Alternative alternative proposal

When I'm on the sidelines waiting for a spec, all I can think about is "why won't this land already?". As I've gotten closer to "deciding specs", all I can think of it "what if this one tweak solved all the problems in the world?"...it's exhausting, thanks spec writers and implements!!!

I like shadowrootsemanticdelegate and it's relation to shadowrootdelegatesfocus so much that I wonder if we should simply (as if anything could be simple in this space) have a delegatesAria property on el.attachShadow and a shadowrootdelegatesaria attribute added to template? That could allow for all aria attributes to be passed down and up with delegate... or reflect... (or better as the spec solidifies) attributes on element within the shadow DOM. And author no longer configures the management at the shadow boundary it is automatic to the attributes included within the actual Shadow Root wherein this property is activated, while giving the ability to pass attributes in both direction from any element within that Shadow Root. I could also see this benefitting from an aria-delegate attribute that could pin all pass through attributes to a single child element. This sort of implies to me that delegateariaattributes should be a trinary attribute (like many aria attributes) where in absent or "false" means nothing is delegated, present with no value means delegate all aria attributes, and token list delegates a selected number of attributes to delegate. 🤔

In this way, a part of the Combobox pattern outlined in the Aria Practices guide could be labeled (other relationships excluded for brevity) as follows

<label id="cb1-label">
  State
</label>
<x-combobox aria-labelledby="cb1-label">
    <template shadowrootmode="open" shadowrootdelegatesaria>
        <input delegatedariaattributes="aria-labelledby">
        <button delegatedariaattributes="aria-labelledby"></button>
        <ul delegatedariaattributes="aria-labelledby">
            <li role="option">...</li>
        </ul>
    </template>
</x-combobox>

The above does point to one thing that I've not seen fully addressed in the various proposals that I've see and it's the label application ensured by the for attribute. You'll notice that I switched the above to aria-labelledby instead of using for and aria-label as seen in the source to reduce content duplication. Is for inherently included in the "aria attributes" even though it isn't name spaced aria-...? This further asks the question (that has been brought up elsewhere) what about non-"aria" attributes?

Scary bigger idea

The following it entirely exciting to me, but presents a while lot of problems and questions that have yet to be worked out even in my mind and will likely provoke even more problems and questions from others, but I wanted to get it into the world while thinking about it.

One problem that does arise often when "wrapping" a native element is the ability to easily apply all of the native attributes available to that element. At some point someone will want to leverage the readonly attribute on the <input> in your <x-input> element, and while some may say that customized built-ins would solve this problem, I'm not sure that it does. If you customize and <input> and <input is="x-input"> you may be able to customize or extend some of its functionality, but you don't have the options to customize its style delivery in an easily repeatable fashion or to customize the content which which it is delivered due to the fact that an <input> cannot have a Shadow Root attached to it in user space (see the private Shadow Root already applied by the user agent). In this way, "how do I pass all the attributes down to my wrapped element?" comes up very often, both when wrapping native elements and when rapping other custom elements. What if we went totally bonkers here and instead of making an shadowrootdelegatesaria attribute for <template> we make it shadowrootdelegatesattributes? Pair this with the aria-delegate attribute (now a attribute-delegate), and you could automagically have your cake and eat it to with an <x-input> that looked like:

<x-input readonly value="Value passed to internal input">
    <template shadowrootmode="open" shadowrootdelegatesattributes>
        <input delegateattributes />
    </template>
</x-input>

There are more attributes in the world than those on an <input> so the open pass will likely cause issues here. "Attributes" not having a scope the way "aria attributes" does might point to the requirement of an include list here, but even if that was something that had to be, element developers would be greatly benefitted by this expansion of capabilities.

@nolanlawson
Copy link

However, I might solve your contrived example via a different DOM structure: https://codepen.io/Westbrook/pen/YzjmjKe

This is a neat trick (using the <slot> as a workaround for ID scoping), but I don't think it would work well in the case where you have a pre-existing element in some sibling shadow root that you want to use as the label, e.g. you have an <h1 id="foo"> and you want aria-labelledby="foo". (To make that work, you would need to serialize the accessible name of the h1 and then use "screen-reader only" styles when rendering it inside the <label>… Not pretty.)

I know I keep harping on these edge cases, and part of the reason may be Salesforce's unique experience: we've shipped a shadow DOM polyfill for years (including in browsers that support shadow DOM, long story), which means we have a kind of natural experiment of "what if shadow DOM, but no ARIA idref issues?" In that world, I think people are tempted to build components the way they would in any light-DOM-based component system (React, Vue, etc.) without workarounds that involve changing the DOM structure.

Is for inherently included in the "aria attributes" even though it isn't name spaced aria-...? This further asks the question (that has been brought up elsewhere) what about non-"aria" attributes?

Technically no, the only non-"aria" one I see in the ARIA Mixin is role. But you're totally right; for is equally useful here (as are the other ones you helped find 🙂 ).

@Westbrook
Copy link

I don't think it would work well in the case where you have a pre-existing element in some sibling shadow root that you want to use as the label

100%! The approach here is very much a "if I were to do it from scratch and support this one specific use case" sort of technique.

On the idea of:

the case where you have a pre-existing element in some sibling shadow root

Do you feel like Alice and I are on a reasonable path to call delegating/importing/applying or reflecting/exporting/providing multiple of one type of aria from a single element a bridge too far in way of things a final API in this area should look to support? "what if shadow DOM, but no ARIA idref issues?" would technically allows that, so knowledge from the experiments that team has been running would be quite useful in tracking a path forward there.

I'd personally wonder with the (pending?) advent of CSS @scope pointed to a hard requirement of exporting/importing in this was implied that your polyfill was more for @scope than for shadowRoot, maybe without even knowing it? However, the more we ask for the more of we get of what we want, right?

Clarification re: for

This is brought up in the context of "attribute passing", but, while I'd want to pass for (yay, cool!), the real question is how to pass the applied "label" that for provides.

<label for="next">Label for next</label>
<x-input id="next">
    #shadow-root
        <input />
</x-input>

Is there a version of this spec (possibly covered implicitly by aria-label? or something else?) where the <input /> above can be given the label "Label for next"?

@nolanlawson
Copy link

implied that your polyfill was more for @scope than for shadowRoot, maybe without even knowing it?

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 🙂).

Do you feel like Alice and I are on a reasonable path to call delegating/importing/applying or reflecting/exporting/providing multiple of one type of aria from a single element a bridge too far

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.

@alice
Copy link
Author

alice commented Feb 15, 2023

@nolanlawson

AIUI, this solves both the "delegation" and "reflection" cases (using the terminology from that spec), but only when there is a 1-to-1 mapping between elements inside and outside the shadow root. ... The main upside I see with this proposal is that it's a lot less typing, and a component author doesn't have to know in advance which aria* attributes they want to expose.

Yes, exactly. It wouldn't solve every case, or even every case which the reflection/delegation APIs could solve, but it would solve a large chunk with an extremely easy to use and easy to read API. Moreover, it acknowledges and directly addresses a common pattern of custom element authoring.

Neither spec, though, seems to solve the "bottleneck" problem.

Indeed, and I find it hard to envision a spec which solves problem 3 that doesn't have some iteration of the bottleneck issue.

A question for you and @Westbrook would be: what might be some working examples of the cases which fall outside of what the more limited semanticDelegate API could solve? I don't doubt they exist, but you're the ones working in this day to day, so I feel you're better placed to point to specific examples. I think if we had a good "menagerie" of these examples, we could do some work to try and figure out whether there are more patterns that could be directly addressed, or whether the more general delegation/reflection APIs are truly the best way forward for those cases.

@Westbrook

you can't share a <template> that contains state with the currently available DOM APIs. Maybe something like DOM Parts will change that in the future, but let's not wait for that to get this into the platform!

I'm certainly not proposing (and I assume Nolan wasn't either) that we wait for DOM Parts or any other API to land!

However, a question: would DOM Parts landing and meaning that shareable <template>s with individual state would become possible lead to the issue I point to becoming an actual issue? Would anticipating that issue motivate trying to find a way to declaratively refer across shadow roots in a way that doesn't have the bottleneck issue I illustrate here, which could land in time to be useful when/if DOM Parts lands?

"Provides a ..." feels like the perfect definition of a "wrapper" element, while being the reciprocal of a "is provided a ..." which matches a "decorator" element.

I really like the "provides a"/"is provided a" language, but I think "provides a" is better suited to something like an aria-activedescendant target, as you allude to in the later <x-listbox> example - I agree that the <x-listbox> is a listbox, and I think something which wraps an <input> often acts as though it is an <input>.

To me, "provides a" suggests that the thing that's being provided will be accessible in some way (i.e. in a carefully designed way that preserves encapsulation) from outside of the thing that's providing it - more like my "reverse slot" idea. The "semantic delegate" idea is really intended to match the "is a" pattern, and as such won't "provide" the element that is acting as the semantic delegate - rather that element "merges with" the host element.

I really like the closer parallel that you are proposing here between aria and focus.

That actually wasn't my intention - I guess it's an unfortunate side effect of me not being able to come up with a less confusing name than "semantic delegate". Note that it's "DELegate" as a noun, as opposed to "deleGATE" as a verb. delegatesFocus is also a poorly chosen name - it doesn't delegate focus to any particular element, but rather makes the shadow host transparent to focus, while allowing tabindex on the host to be respected. All focusable elements within the shadow root remain in the focus order.

In my view, this simply isn't possible to analogise with redirecting attributes from the shadow host - the nature of focus is that there is an ordered collection of focusable elements within each focus navigation scope, but the nature of attributes is that they apply specifically to the element that hosts them.

a) the APIs currently available directing the implementations in a specific direction

How so?

A design system is much more likely to feature the one to one relationship outlined by shadowrootsemanticdelegate

Why is that?

I do not believe that to outline the whole of the Custom Element development ecosystem.

Nor do I, as I noted. However, I do still think it is a decent proportion of cases - i.e. all (?) the is a cases.

For every one to one element implementation you're likely to see you <custom-combobox> from above or an <x-addresses-list> element that manages the data fetching you referenced for the list of all addresses in the world that composes an <x-listbox> and <x-option> elements within it so that sometimes it can be leveraged as part of a "select" UI, other as part of a <datalist>, and yet more as a full on "combobox".

Hm, I think each of these is a something (the combobox is a specialised text field; the address list is a list) while also providing an element which can be selected as an aria-activedescendant. So these don't really look to me like counterexamples per se, but more like examples of why we need a way to express the "provides a" idea on top of is a.

I'd like to stand against the idea of a reverse or special GUIDs as steadfastly as possible. The implementation detail leakage that it implies greatly endangers the refactorability of having your DOM and JS encapsulated within a Shadow Root.

I'm not convinced a "reverse slot" API need necessarily imply more detail leakage than, say, part - semantically, it would be saying "there will be an element here (for some vague concept of "here" that I haven't fleshed out) that can be targeted by any IDREF type relationship" in the same way that part is saying "there will be an element with this part name which can be targeted by styles'.

Specifically, its being a "slot" would place the element in question back in light DOM, meaning its tree walking APIs wouldn't leak shadow DOM internals.

However, it may well be that something more like the reflectAriaX is a better way to express this - with the acknowledged limitations that:

  • it can only "reflect" one element or set of elements (such that anything referring to the host element refers to all of the reflected elements) per attribute, and
  • it makes what is being reflected "opaque": for example, you couldn't log the text contents of the "deep" active descendant.

For "special GUIDs", I was just chatting to Brian about this, and he agrees it's less simple than he'd hoped - the idea was that you could use a secondary attribute e.g. "globalId" to opt in to an ID being in a global ID namespace for the page, meaning you'd need to do the same avoidance of duplicate IDs as you do in a complex non-shadow DOM page, but any element could refer to a globalId via any IDREF type attribute. However, again, it would have to be carefully specified such that you can't "accidentally" walk into shadow DOM internals from getElementById or any of the element reflection APIs.

The only thing that a consumer or author should be worrying about in this area is whether something is provided by an element (consumer) and whether something needs to be applied form the outside (author), not how it come there or from where it comes.

I think we're on much the same page with this as regards any kind of "reverse slot" API.

The "special GUID" type API would be more for edge cases were you truly just need to do something weird and have it be serializable. In order to even use it, you would have to have control over every part of the shadow DOM to begin with, in order to set the globalId in the first place. It would be the declarative equivalent of using element references to refer to arbitrary elements within shadow roots.

@Westbrook
Copy link

I'll look at gathering elements for the "menagerie".

Would anticipating that issue motivate trying to find a way to declaratively refer across shadow roots in a way that doesn't have the bottleneck issue I illustrate here, which could land in time to be useful when/if DOM Parts lands?

Interesting problems space/question... I don't know enough to say definitively, but IF IDREF becomes the way you share <template>s at DSD time, it would have to work based off of some trick of the DOM being flat just before the DSD is hydrated by the browser, which should preclude this being an issue in referencing things, IMO. The "elements in a shadow root can't know/expect the outside of the shadow root to look a specific way" rule seems to apply here, otherwise. For it to really work right, I think you'd have to reference those elements out of bounds of the currently available APIs for it to really work as expected. Those APIs being blocked by shadow roots is definitely an issue to keep top of mind in those specs, but them being "shadow DOM-centric", I'd hope it didn't become a problem.

I think "provides a" is better suited to something like an aria-activedescendant target

This feels like only the first step, however. With:

<x-listbox>
   #shadow-root
       <x-options aria-activedescendant>
</x-listbox>

You 100% "provide a" aria-activedescendant from an element that "is a" listbox, but if you are using the listbox as a style container that then needs to list all addresses in one place, all genus in another, ad infinitum, you could easily run into the following:

<x-addresses>
    #shadow-root
         <x-listbox>
              #shadow-root
                  <x-options aria-activedescendant>
         </x-listbox>
</x-addresses>
<x-genus>
    #shadow-root
         <x-listbox>
              #shadow-root
                  <x-options aria-activedescendant>
         </x-listbox>
</x-genus>

Wherein the <x-addresses> and <x-genus> elements not provide both the aria-activedescendent and the listbox. There is definitely an alternative approach wherein you extend the x-listbox rather than composing it, or you provide it to the element rather then have the element provide it, but here is where the tight one-to-one relationship starts to run into friction.

To me, "provides a" suggests that the thing that's being provided will be accessible in some way (i.e. in a carefully designed way that preserves encapsulation)

I've not spent enough time with the Element Internals and Form Associated Custom Elements specs, but I'd say this is the reason that you'd say <x-input> provides an input more than it is an input. Unless you map things down to it a label on the <x-input> does not apply to the <input> in the accessibility tree that is created. In a one-to-one shadowrootsemanticdelegate relationship, maybe you would be binding that relationship more in that way?

delegatesFocus is also a poorly chosen name

Possibly, but while it make the shadow boundary transparent to the tab order, it does deleGATE the focus() method called on the host to the first focusable element in the DOM order of the shadow root. Which is interesting because it is inherently different than leveraging the tab order to enter the same part of the DOM, which would leverage the value of the tabindex attributes to decide where to focus first.

the nature of attributes is that they apply specifically to the element that hosts them.

It would seem that we are altering this nature by whatever form of this API we land on, though, right?

a) the APIs currently available directing the implementations in a specific direction

There are only so many "accessible" ways to create an <x-input> based on what's available today: https://dev.to/westbrook/testing-accessibility-with-shadow-roots-55cm This roughly applies to other aria relationship to varying degrees of strictness. In that way, we've been trained to build things this way. Take that in parallel with the style APIs available over the last number of years vs today, and it's highly likely that with tools like grid, container queries, x-root aria, and more the elements would be structured much differently that they are today.

A design system is much more likely to feature the one to one relationship outlined by shadowrootsemanticdelegate

"Why is that?" I want to say because most design systems focus on the atomic patterns of that system, but it goes beyond that, I think. This question may be the most important to the over shape of this spec. Is it because of it being "right" or because of the "focus" that a design system takes, or is it fully a side effect of API space of today as outlined above? The answer (which I'll need to think on more) points directly to the ability/need to leverage one-to-one, bottleneck-to-many, or many-to-many relationships here. For example an <x-listbox> feels like it needs to be able to provide an aria-activedescendent while also being a listbox, but maybe that pokes out at the top of what this API should surface? In which case would we need to lock down a set of relationships that are possible to relate that way?

but more like examples of why we need a way to express the "provides a" idea on top of is a.

Maybe a provides a API is needed on top, but those elements are not just surfacing things, they're also accepting things. While an <x-combobox> "provides a" listbox, that listbox also needs to accept a label just the same as the <input> that the combobox is and/or provides. So while it may not be possible for one API to do both, both APIs need to do close to the same things for different relationships.

there will be an element

I may be getting overly stuck on personal interpretation of that part API as not saying there will be an element, but like all CSS saying "if there is an element that settles this contract" apply these rules. Maybe in that way projecting content out and a user pointing to content that might be there is OK. ::part() does, however, follow my logic of being an implementation detail that incurs the cost of leakage, as it becomes something that you have to maintain. It would be nice if an element could say "I have this aria info if you want it" without the host application having to say "I'm taking this aria info from this special place in you" but that may be semantics?


Beyond this great discussion, what do you see as good next steps for this space? It would be nice to have a "complete concept", for whatever it's worth, to present as a path forward, if everyone was one board, in April.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment