Reference Target: having your encapsulation and eating it too
Three years ago, I wrote a blog post about How Shadow DOM and accessibility are in conflict.
I explained how the encapsulation provided by shadow roots is a double-edged sword, particularly when it comes to accessibility. Being able to programmatically express relationships from one element to another is critical for creating user experiences which don’t rely on visual cues - but elements inside a shadow root aren’t available to be referenced from elements in the light DOM. This encapsulation, however, is what allows component authors to create accessible components which can be safely reused in any context, without necessarily requiring any particular dependencies or extra build steps.
In the year or so following, even more heroic attempts were made to square this circle, and finally one seems likely to stick: Reference Target. In this post I’ll explain how this feature works, why I like it, and what the situation is right now with the spec and implementation (thanks in part to Igalia’s NLNet funding).
A quick introduction #
referenceTarget is a new property on shadow root objects which lets you nominate an element in the shadow root’s subtree which should be the target of any attribute-based reference to the shadow host.
As an example, imagine that you have a <custom-input> component, which has an <input> tucked away in its shadow root.
This is a pattern which is ubiqutous in custom element libraries, as it allows the custom element to use composition to enhance the behaviour of a built-in element.
<label for="track">Track name:</label>
<custom-input id="track">
#shadowRoot
| <input id="inner-input">
</custom-input>
We can set the shadow root’s referenceTarget to allow the <label> to correctly label the inner <input>:
// in the constructor for the custom-input:
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.referenceTarget = 'inner-input';
shadowRoot.innerHTML = '<input id="inner-input">`;
This lets the label refer to the <custom-input> just like it would refer to an <input>; the <custom-input> transparently proxies the reference through to the encapsulated <input>.
In this example, we’ve set the referenceTarget property directly on the ShadowRoot object, but it can also be set declaratively when using the <template> element to create the shadow root:
<label for="track">Track name:</label>
<custom-input id="track">
<template shadowRootMode="open"
shadowRootReferenceTarget="inner-input">
<input id="inner-input">
</template>
</custom-input>
This works equally well for any attribute which refers to other elements like this - even if you set it via a reflected property like commandForElement:
<button id="settings-trigger">Site settings</button>
<custom-dialog id="settings-dialog">
#shadowRoot referenceTarget="inner-dialog"
| <dialog id="inner-dialog">
| <button id="close" aria-label="close"
| commandFor="inner-dialog"
| command="request-close"></button>
| <slot></slot>
| </dialog>
<fieldset>
<legend>Colour scheme:</legend>
<label for="dark">
<input type="radio" id="dark" name="appearance" value="dark" checked>
Dark
</label>
<!-- TODO: more colour schemes -->
</fieldset>
</custom-dialog>
// Someone probably has a good reason why they'd do it this way, right?
const settingsButton = document.getElementById('settings-trigger');
settingsButton.command = 'show-modal';
settingsButton.commandForElement = document.getElementById('settings-dialog');
This lets the <custom-dialog> behave exactly like a <dialog> for the purposes of the command and commandForElement properties.
Why I like it #
In my earlier blog post I explained that I was concerned that the Cross-root ARIA delegation and reflection proposals introduced a bottleneck problem. This problem arose because it was only possible to refer to one element per attribute, rather than allowing arbitrary cross-shadow root references.
This proposal absolutely doesn’t solve that problem, but it reframes the overall problem such that I don’t think it matters any more.
The key difference between reference target and the earlier proposals is that reference target is a catch-all for references to the shadow host, rather than requiring each attribute to be forwarded separately. This solves a specific problem, which I alluded to above: how can custom element authors encapsulate the behaviour of a given built-in HTML element while also allowing other elements to refer to the custom element as if it was the built-in element?
I believe this more narrow problem definition accounts for a significant proportion - not all, but many - cases where references need to be able to cross into shadow roots. And it makes the API make much more sense to me - if you’re using the for attribute to refer to a <custom-input>, you’re not meant to need to know that you’re actually referring to an enclosed <input>, you just want the <custom-input> to be labelled. This API makes the enclosed <input> an implementation detail. And since a shadow root can only have one host, it makes sense that it can only have one reference target.
Adjacent, as-yet unsolved problems #
Arbitrary cross-shadow root references #
As mentioned above, one adjacent problem is the problem of element references which do need to refer to specific elements within a shadow root, rather than a stand-in for the shadow host.
The explainer gives two examples of this: aria-activedescendant on a combobox element which needs to refer to an option inside of a shadow root, and ARIA attributes like aria-labelledby, aria-describedby and aria-errormessage which may need a computed name for the component which excludes some parts.
I think we need to be careful about generalising this problem, though. As I describe later in the explainer, I think we might be able to get better solutions by solving more specific problems - as we have with reference target.
If you have another example of where you need to refer to specific elements within a shadow root, you can leave a comment on this issue collecting use cases.
Attribute forwarding #
While reference target allows other elements to refer to the encapsulated element, custom element authors may also want to allow developers using their component to use standard HTML and ARIA attributes on the host element and have those apply to the encapsulated element.
For example, you might like to support popoverTarget on your <custom-button> element:
<custom-button popoverTarget="languages">Language</custom-button>
<custom-menu id="languages" popover>
<custom-menuitem>Nederlands</custom-menuitem>
<custom-menuitem>Fryslân</custom-menuitem>
<custom-menuitem>Vlaams</custom-menuitem>
</custom-menu>
There is an issue for the attribute forwarding idea; leave a comment there if this is an idea you’d like to see pursued.
Form association #
Custom elements can be specified as form-associated, but there’s no way to associate an encapsulated form-associated built-in element (such as <input>) with an enclosing <form>.
For example, the <custom-input> above could be nested in a <form> element, but the enclosed <input> wouldn’t be associated with the <form> - instead, you’d have to use setFormValue() on the custom element and copy the value of the <input>.
Spec and implementation status #
In brief: the spec changes seem to be in good shape, Chromium has the most feature-complete implementation and there are significantly less-baked implementations in WebKit and Firefox.
Spec changes #
There are open pull requests on the HTML and DOM specs. Since these PRs are still being reviewed, the concepts and terminology below might change, but this is what we have right now. These changes have already had a few rounds of reviews, thanks to Anne van Kesteren, Olli Pettay and Keith Cirkel.
The DOM change:
- adds the concept of a reference target
- adds the
referenceTargetproperty to theShadowRootobject.
The HTML change is where the actual effect of the reference target is defined.
Element reference attribute type #
One key change in the HTML spec is the addition of an attribute type for “element reference” attributes. This formalises in HTML what has previously been referred to as an ID reference or IDREF. This term isn’t currently used in HTML, and since the addition of reflected IDL Element attributes, IDs aren’t strictly necessary, either.
Before this change, whenever an attribute in the HTML spec was required to match another element based on its ID, this was written out explicitly where the attribute was defined. For example, the definition of the <label> element’s for attribute
currently reads:
The
forattribute may be specified to indicate a form control with which the caption is to be associated. If the attribute is specified, the attribute’s value must be the ID of a labelable element in the same tree as thelabelelement. If the attribute is specified and there is an element in the tree whose ID is equal to the value of the for attribute, and the first such element in tree order is a labelable element, then that element is thelabelelement’s labeled control.
Since reference target affects how this type of reference works, and is intended to apply for every attribute which refers to another element, it was simpler to have one central definition.
Reference target resolution #
For a reference target to actually do something, we need to define what effect it has. This is defined, quite straightforwardly, in the steps to resolve the reference target:
- If element is not a shadow host, or element’s shadow root’s reference target is null, then return element.
- Let referenceTargetValue be the value of element’s shadow root’s reference target.
- Let candidate be the first element in element’s shadow root whose ID matches referenceTargetValue.
- If no such element exists, return null.
- Return the result of resolving the reference target on candidate.
These steps are recursive: if a shadow root’s reference target has its own shadow root, and that shadow root has a reference target, we keep descending into the nested shadow root.
One slightly subtle design choice here is that if a shadow root has a reference target which doesn’t refer to any element - for example, an empty string, or a value which doesn’t match the ID of any element in its subtree - the resolved reference target is null, not the shadow host.
For example, if you tried to use popoverTarget to refer to a shadow host which had a popover attribute, but had an invalid reference target on its shadow root, the popoverTarget attribute won’t actually target anything:
<button id="more-actions" popoverTarget="actions-popover" aria-label="more actions">…</button>
<!-- Even though this has a popover attribute, the button won't toggle it! -->
<custom-popover id="actions-popover" popover>
<template shadowRootMode="open"
shadowRootReferenceTarget="0xDEADBEEF">
<div id="help-im-trapped-in-a-shadow-root" popover>
<slot></slot>
</div>
</template>
</custom-dialog>
Resolved and unresolved attr target elements #
Like many spec concepts, this one is a real mouthful.
This lets us be very clear about whether reference target resolution has happened when we’re talking about what element an attribute refers to.
If we’re reflecting an attribute to its IDL counterpart, we now use the unresolved attr target element. For example, if we had the DOM defined in the previous example, and we wanted to get the popoverTargetElement for the "settings-trigger" button:
const moreActions = document.getElementById("more-actions");
// This will log the <custom-popover> element (!)
console.log(moreActions.popoverTargetElement);
(In spec terms: the <custom-popover> element is the unresolved popoverTarget target element for the <button>.)
This might also be a bit surprising; we spent quite a bit of time going back and forth on this, since we thought developers might want to know that the popoverTarget isn’t actually targeting anything. However, using the unresolved target lets us have a very close parallel between setting and getting the popoverTargetElement, as well as preserving the shadow root’s encapsulation.
The resolved attr target element, meanwhile, is what will be used when actually doing something with the attribute - such as triggering a popover, or computing a label’s labeled control, or determining an element’s accessible description.
In the above example, the resolved popoverTarget target element for the button is null. And, going back to the examples we’ve seen earlier:
- the resolved
commandFortarget element for the Settings button is the inner<dialog>- clicking the button will open the<dialog>. - the resolved
fortarget element for the<label>is the inner<input>- clicking the label will focus the input, and the input’s computed accessible name will be “Track name”.
“Referring to” #
For convenience, we define the concept of an attribute “referring to” an element:
A single-element reference attribute attr on an element X refers to an element Y as its target if Y is the resolved attr target element for X.
So, for example, the commandFor attribute on the Settings button refers to the inner <dialog> element.
Sets of element references #
All of the above used single element references as examples, but there are attributes which can refer to more than one element. For example, almost all of the ARIA attributes which refer to other elements refer to multiple elements in an ordered list - one such is aria-errormessage, which can refer to one or more elements which should be exposed as specifically as an error message for an element which is marked as invalid.
We define a set of element references attribute type, as well as a couple of subtypes which impose constraints such as ordering or uniqueness, as well as what it means for one of these attributes to refer to another element, and how to get the resolved and unresolved attr target elements for these attributes.
While these are slightly more complex than the single element versions, they follow the same basic logic. The only marginally significant difference is that since they produce lists of elements, if a shadow root’s reference target is invalid, no element is added to the list for that unresolved attr target, instead of returning null.
Using these concepts in the rest of the spec #
Now that we’ve defined these spec concepts, we have to update each place in the spec where we previously used the “whose ID is equal to the value of the blahblah attribute” wording.
Returning to our good friend popoverTarget, we can see a relatively straightforward example.
The definition of the popoverTarget attribute now reads:
If specified, the
popovertargetattribute value must be a valid single-element reference attribute referring to an element with thepopoverattribute.
And now, get the popover target element determines popoverElement like this:
- Let popoverElement be node’s resolved
popovertargettarget element.
<label> association is a bit more complex, since we wanted descendants of the <label> to be correctly labelled when using reference target:
To determine a
labelelement label’s labeled control, run these steps:
- If label’s
forattribute is specified, then:
- If the resolved
fortarget element is not null, and the resolvedfortarget element is a labelable element, return that element.- Otherwise, return null.
- For each descendant descendant of label in tree order:
- Let candidate be the result of resolving the reference target on descendant.
- If candidate is a labelable element, return candidate.
- Return null.
There is also a PR open on the ARIA spec to introduce this terminology there.
Implementation status #
Chromium has the most complete implementation, though it may not quite be up to date with the latest spec changes. Any developers wanting to try it out should get the latest build of Chrome or Edge and flip on the Experimental Web Platform Features flag. If you do try it out, I’d love to hear any feedback you might have!
WebKit and Firefox (tracking bug) each have a prototype implementation, available behind respective feature flags (ShadowRootReferenceTargetEnabled for WebKit and dom.shadowdom.referenceTarget.enabled for Firefox), which should pass at least most of the existing WPT tests - however, the WPT tests are insufficient to test all of the functionality, and the functionality which couldn’t be tested via WPTs hasn’t been implemented yet in these engines. The Chromium implementation included adding many Chromium-specific tests for the behaviour which can’t be tested via WPTs, as well as implementing that behaviour.
Currently, WPT tests can only test the computed accessible name and computed accessible role for an element, as well as testing DOM methods and user actions like clicking. However, reference target impacts the accessibility tree in many ways - not only via ARIA attributes, but via attributes like popoverTarget being exposed in the accessibility tree as an accessible relation.
And, importantly, changes to the accessibility tree can require certain notifications to be fired to assistive technology APIs - and reference target introduces several new ways to change the accessibility tree. Adding, changing, or removing a shadow root’s referenceTarget may cause changes in the resolved target elements for attributes, causing accessibility tree changes and potentially requiring notifications. Likewise, inserting an element with an ID which matches a shadow root’s referenceTarget could also cause a shadow host’s resolved reference target to change, also potentially causing the accessibility tree to change.
There are two complementary projects currently underway which will allow us to write much richer tests for accessibility tree functionality in browsers:
- support for writing WPT tests which directly test what browsers expose to accessibility APIs
- support for an “accessible properties” API.
Once we can write WPT tests which actually test the full spectrum of expected behaviour for reference target, we’ll be able to actually make it an official interop focus area.
The prototype implementation work in WebKit and Firefox, as well as the spec work done by Igalia, was generously funded by a grant from NLNet Foundation, while the implementation work in Chromium and much of the remainder of the spec work was done by Microsoft engineers on the Edge team.