Author: Ben Howell
Reference Target is a feature to enable using IDREF attributes such as for
and aria-labelledby
to refer to elements inside a component's shadow DOM, while maintaining encapsulation of the internal details of the shadow DOM. The main goal of this feature is to enable ARIA to work across shadow root boundaries.
This proposal is based on @Westbrook's Cross-root ARIA Reflection API proposal, as well as borrowing ideas from @alice's Semantic Delegate proposal.
For an in-depth description the cross-root ARIA problem, see @alice's article How Shadow DOM and accessibility are in conflict. The article describes the two main problems that need to be solved:
The existing ARIAMixin IDL attributes (such as ariaLabelledbyElements
and ariaActiveDescendantElement
) unlock part of the solution to the cross-root ARIA problem. They allow for an element inside a shadow DOM to create an ARIA link to an element outside that shadow DOM. However, they are limited in that they can't reference an element inside another component's shadow DOM. The specifics of this limitation are described in more detail in How Shadow DOM and accessibility are in conflict.
The "missing piece" to solving the cross-root ARIA problem is the ability to refer into Shadow DOM. The Reference Target feature described in this explainer intends to solve this problem in a way that is compatible with the ARIAMixin attributes.
When Reference Target is used in conjunction with ARIAMixin, it is possible to create references between elements in sibling shadow DOMs, or between any two unrelated shadow DOMs on the page, as long as the components have provided the API to do so, through reference targets and custom attributes.
Web components have an increasing number of features that allow them to work and act like builtin elements. For example:
- Form-Associated Custom Elements can participate in forms like a builtin input.
- delegatesFocus allows a component to work better with keyboard navigation.
However, there are still missing pieces that prevent a web component from truly being a drop-in replacement for a built-in, including:
- Can't create ID reference links to elements inside a shadow tree.
- Can't use built-in attributes like
aria-label
orrole
on the host and have them apply to an element inside the shadow root. - Non-trivial amount of code required to hook up custom attributes on the host to ARIAMixin attributes on an element inside the shadow root.
- Can't get form-association for "free" by delegating to an input inside.
This proposal solves only the first problem: referring into the shadow DOM. It leaves the other problems to be solved by other features. While all of the problems may seem related, they can be designed separately.
Reference Target is a new feature that enables creating ARIA links to elements inside a component's shadow DOM, while maintaining encapsulation of the internal details of the shadow DOM.
- Solve only the "missing piece" of cross-root ARIA: how to handle IDREF attributes referring into the shadow DOM. Avoid scope creep.
- Create a mechanism for ID reference attributes like
aria-activedescendant
andfor
to refer to an element inside a component's shadow DOM. - Should work the same for both closed and open shadow roots.
- Shadow DOM encapsulation should be preserved: No direct access to any elements inside the shadow tree, and no implementation details leaked into a web component's API.
- Should allow creating references into multiple nested shadow roots, and across "sibling" shadow roots that don't have a direct parent/child relationship.
- The solution should be serializable, i.e. support declarative syntax that is expressible in HTML without needing JavaScript.
- This is scoped to only solve the problem of referring into the shadow DOM. It relies on ARIAMixin to refer out of the shadow DOM.
- This feature does not solve the bottleneck effect. It is difficult to find a compelling real-world example where this is a problem.
- This does not affect how attributes set on the host element work. For example, this does not tackle the problem of forwarding
role
oraria-label
, etc. from the host element to an element inside.
This proposal is broken into two phases:
- Phase 1 adds the ability to designate a single element as the target for all IDREF properties that refer to the host.
- Phase 2 adds a way to re-target specific properties (like
aria-activedescendant
) to refer a separate element.
The goal of breaking it into phases is to get the simpler syntax and simpler use cases working first. The solutions to Phase 2 are more complex and may need more discussion before they are ready.
A component can specify an element in its shadow tree to act as its "reference target". When the host component is the target of a IDREF like a label's for
attribute, the referenceTarget becomes the effective target of the label.
The shadow root specifies the ID of the target element inside the shadow DOM. This is done using one of the following methods:
- The
referenceTarget
entry in theShadowRootInit
argument toattachShadow()
. - The
referenceTarget
attribute on theShadowRoot
object. - In HTML markup using the
shadowrootreferencetarget
attribute on the<template>
element.
JavaScript example:
<script>
customElements.define(
"fancy-input",
class FancyInput extends HTMLElement {
constructor() {
super();
this.shadowRoot_ = this.attachShadow({
mode: "closed",
referenceTarget: "real-input",
});
this.shadowRoot_.innerHTML = `<input id="real-input">`;
// Optionally, set referenceTarget on the ShadowRoot object.
// Not needed in this case since it was set in attachShadow() instead.
// this.shadowRoot_.referenceTarget = "real-input";
}
}
);
</script>
<label for="fancy-input">Fancy input</label>
<fancy-input id="fancy-input"></fancy-input>
Equivalent with declarative shadow DOM:
<label for="fancy-input">Fancy input</label>
<fancy-input id="fancy-input">
<template
shadowrootmode="closed"
shadowrootreferencetarget="real-input"
>
<input id="real-input">
</template>
</fancy-input>
This feature is intended to work with all attributes that refer to another element by ID string. These are:
- ARIA
aria-activedescendant
aria-controls
aria-describedby
aria-details
aria-errormessage
aria-flowto
aria-labelledby
aria-owns
- Inputs
for
(also supports the click behavior of labels)form
list
popovertarget
anchor
(proposed in the Popover API Explainer)commandfor
(proposed in Invokers Explainer)interesttarget
(proposed in Invokers Explainer)
- Tables
headers
Please comment if there are any attributes missing from this list.
There are situations where it is necessary to target different reference types to different elements. For example, a listbox may want to target aria-controls
to its root, and aria-activedescendant
to one of the items inside the listbox.
The ShadowRoot.referenceTargetMap
attribute allows for specifying target elements based on the attribute that is being used to reference the host.
The equivalent declarative attribute is shadowrootreferencetargetmap
, which is a comma-separated list of attribute to ID mappings.
Note: the syntax of
shadowrootreferencetargetmap
is based on theexportparts
attribute that contains a comma-separated map of part names.
<input
role="combobox"
aria-controls="fancy-listbox"
aria-activedescendant="fancy-listbox"
/>
<fancy-listbox id="fancy-listbox">
<template
shadowrootmode="closed"
shadowrootreferencetargetmap="aria-controls: real-listbox,
aria-activedescendant: option-1"
>
<div id="real-listbox" role="listbox">
<div id="option-1" role="option">Option 1</div>
<div id="option-2" role="option">Option 2</div>
</div>
</template>
</fancy-listbox>
The JavaScript API reflects the mappings using camelCase names for the properties, and htmlFor
for for
:
this.shadowRoot_.referenceTargetMap.ariaControls = "real-listbox";
this.shadowRoot_.referenceTargetMap.ariaActiveDescendant = "option-1";
this.shadowRoot_.referenceTargetMap.htmlFor = "real-input";
Reference targets are a "live reference". Any of the following changes could result in an element reference being updated:
- The host changes its
referenceTarget
orreferenceTargetMap
to refer to a different ID. - An element with an
id
that matches its host's referenceTarget is added to or removed from the host's shadow tree. - The
id
attribute of an element inside the host's shadow tree is changed to or from the referenceTarget ID. - The host is added or removed from the DOM.
- The host's
id
attribute is changed.
In the example above, if the aria-activedescendant
mapping is changed, then the aria-activedescendant
of <input>
will be changed to refer to the newly-mapped element.
- Before:
<input aria-activedescendant="fancy-listbox">
initially maps to 'option-1'. - fancy-listbox internally updates its mapping:
this.shadowRoot_.referenceTargetMap.ariaActiveDescendant = "option-2";
- After:
<input aria-activedescendant="fancy-listbox">
now maps to 'option-2', without needing to update the input element itself.
In the case where both attributes are specified, referenceTargetMap
takes priority for individual attributes, and referenceTarget
acts as the fallback for attributes that are not specified.
In the example below, "real-listbox"
is the target for all attributes except aria-activedescendant
, which is targeted to "option-2"
.
<input
role="combobox"
aria-controls="fancy-listbox"
aria-activedescendant="fancy-listbox"
/>
<fancy-listbox id="fancy-listbox">
<template
shadowrootmode="open"
shadowrootreferencetarget="real-listbox"
shadowrootreferencetargetmap="aria-activedescendant: option-2"
>
<div id="real-listbox" role="listbox">
<div id="option-1" role="option">Option 1</div>
<div id="option-2" role="option">Option 2</div>
</div>
</template>
</fancy-listbox>
Some attributes such as aria-labelledby
, aria-describedby
, etc. support multiple targets. Using referenceTargetMap
with those attributes support a space-separated list of IDs.
This example shows a <description-with-tooltip>
component that contains a "More Info" button to show the tooltip but is not intended to be included in the description text. It targets aria-describedby: message tooltip
to forward to only the content that should be included in the description text.
<input aria-describedby="description-with-tooltip" />
<!--
The resulting description text is:
"Inline description text. Tooltip with more information."
-->
<description-with-tooltip id="description-with-tooltip">
<template
shadowrootmode="closed"
shadowrootreferencetargetmap="aria-describedby: message tooltip"
>
<div>
<span id="message">Inline description text.</span>
<button onmouseover="showTooltip()" onmouseout="hideTooltip()">More Info</button>
<div id="tooltip" role="tooltip" style="display: none">Tooltip with more information.</div>
</div>
</template>
</description-with-tooltip>
The referenceTarget does not affect CSS selectors in any way. An ID selector will target the host element that has the matching id
attribute, and not its referenceTarget.
A form-associated custom element supports being the target of a label's for
attribute. But if the element has a Reference Target for the for
attribute, then the label applies to the target instead. There are no other changes to the behavior of a form-associated custom element.
Reference Target allows for labels to be implicitly associated with the target element when the host is nested inside a <label>
element. The shadow tree's reference target will be associated with the label that contains the element. If the shadow tree is using referenceTargetMap
, this uses the for
attribute from the map.
In the following example, the label of the <input id="real-input">
is "Fancy input".
<script>
customElements.define(
"fancy-input",
class FancyInput extends HTMLElement {
constructor() {
super();
this.shadowRoot_ = this.attachShadow({
mode: "closed",
referenceTarget: "real-input",
// Alternatively, set the referenceTargetMap with the `for` attribute:
// referenceTargetMap: { htmlFor: "real-input" },
});
this.shadowRoot_.innerHTML = `<input id="real-input" />`;
}
}
);
</script>
<label>
Fancy input
<fancy-input></fancy-input>
</label>
Reference target does not change the behavior of the host element when it is nested inside a form. It does not implicitly associate the target element with the form if it is not a form-associated custom element.
Some JavaScript attributes reflect HTML attributes as Element objects rather than ID strings. These include:
ARIAMixin.ariaActiveDescendantElement
ARIAMixin.ariaControlsElements
ARIAMixin.ariaDescribedByElements
ARIAMixin.ariaDetailsElements
ARIAMixin.ariaErrorMessageElements
ARIAMixin.ariaFlowToElements
ARIAMixin.ariaLabelledByElements
ARIAMixin.ariaOwnsElements
HTMLButtonElement.interestTargetElement
HTMLButtonElement.popoverTargetElement
HTMLElement.anchorElement
HTMLInputElement.form
HTMLInputElement.labels
HTMLInputElement.list
HTMLLabelElement.control
These will never directly return the referenceTarget element that's inside the shadow tree. This is because an IDL attribute with type Element can only refer to an element that is a descendant of a shadow-including ancestor of the element hosting the attribute.
Instead, most attributes return the host element that they're targeting, as long as the attribute's expected type is HTMLElement
. However, The .form
and .list
attributes will return null
when used with a referenceTarget, because they are expected to be HTMLFormElement
or HTMLDataListElement
and the host element itself is not a form or datalist. Importantly, the underlying association will still exist: the input will be connected to the form, for example; it's just not reflected by the .form
attribute.
Note: It may be possible to add new attributes
.formElement
and.listElement
, which could return the host element. However, that is beyond the scope of this proposal.
In the example below, input.ariaControlsElements
is the <fancy-listbox>
element that was targeted by aria-activedescendant="fancy-listbox"
, even though the active descendant internally targets <div id="option-2">
.
<input id="input" aria-controls="fancy-listbox" />
<fancy-listbox id="fancy-listbox">
<template
shadowrootmode="open"
shadowrootreferencetarget="real-listbox"
>
<div id="real-listbox" role="listbox">
<div id="option-1" role="option">Option 1</div>
<div id="option-2" role="option">Option 2</div>
</div>
</template>
</fancy-listbox>
<script>
const input = document.getElementById("input");
console.log(input.ariaControlsElements);
// Logs: [<fancy-listbox id="fancy-listbox">]
</script>
This example shows a submit button connected to a form inside a shadow tree. The button's .form
attribute returns null
, but the button is still associated with the form, and clicking it will submit the form.
<button id="submit" type="submit" form="fancy-form">Submit</button>
<fancy-form id="fancy-form">
<template
shadowrootmode="open"
shadowrootreferencetarget="real-form"
>
<form id="real-form"></form>
</template>
</fancy-form>
<script>
const submit = document.getElementById("submit");
console.log(submit.form); // Logs: null
submit.click(); // Submits <form id="real-form">
</script>
The HTMLInputElement.labels
attribute returns list of the label elements targeting a certain input element. This API should continue to work if the input element is itself the target of a custom element. The labels will be in shadow-including tree order.
Since custom elements inherit from HTMLElement
and not HTMLInputElement
, they don't have a labels
attribute. However, if the custom element is form-associated and has a referenceTarget
, then ElementInternals.labels
will return an empty list []
, since all labels are forwarded to the reference target and not associated with the custom element itself.
<script>
customElements.define(
"form-input",
class FormInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachShadow({
mode: "open",
referenceTarget: "real-input",
});
this.internals = this.attachInternals();
this.shadowRoot.innerHTML = `
<label id="inner" for="real-input">Inner</label>
<input id="real-input" />
`;
}
}
);
</script>
<label id="before" for="form-input">Before</label>
<form-input id="form-input"></form-input>
<label id="after" for="form-input">After</label>
<script>
const formInput = document.getElementById("form-input");
console.log(formInput.labels);
// undefined
console.log(formInput.internals.labels);
// []
const realInput = formInput.shadowRoot.getElementById("real-input");
console.log(realInput.labels);
// [<label id="before">, <label id="inner">, <label id="after">]
</script>
No considerable privacy or security concerns are expected, but community feedback is welcome.
This section covers some design alternatives, along with discussion of their Pros and Cons, and why they were not included in the design.
The name "reference target" (shadowrootreferencetarget
) follows the naming convention of other newer attributes used for IDREFs, such as popovertarget
or invoketarget
. Some possible alternative names:
- "Reference Delegate" -
shadowrootreferencedelegate="id"
- original name for this proposal - "Delegates References" -
shadowrootdelegatesreferences="id"
- more similar wording toshadowrootdelegatesfocus
. - "Reflects References" -
shadowrootreflectsreferences="id"
- borrowing from the Cross-root ARIA Reflection API proposal. - "Forwards References" -
shadowrootforwardsreferences="id"
- borrowing from "forwardRef" in React.
Ultimately, the name "reference target" is the most concise and consistent, and conveys the intent of the feature. However, community feedback is welcome on the name.
The current API of referenceTarget
is a string only, and targets the element by ID. An alternative would be to include an attribute like referenceTargetElement
, which allows specifying element objects (without an ID).
const input = document.createElement("input");
this.shadowRoot_.appendChild(input);
this.shadowRoot_.referenceTargetElement = input;
- Makes the API more flexible by not requiring an ID to be added to the target element.
- It does not unlock any net-new functionality. Since
referenceTarget
only works with elements inside the shadow root, every element that could be a target is accessible by a string ID reference.Note: This is in contrast to the ARIAMixin attributes like
ariaLabelledByElements
, which do unlock the new functionality of referring out of the shadow DOM. In that case, the complexity is necessary to include in the ARIAMixin design. - At a basic level, Reference Target is augmenting the existing functionality of referring to elements by ID string. It seems in line with the design to require using ID strings.
- It requires adding support for attribute sprouting to sync the
shadowrootreferencetarget
attribute withreferenceTargetElement
. This adds complexity to the spec.
An alternative to a single attribute shadowrootreferencetargetmap
/ ShadowRoot.referenceTargetMap
would be to have individual attributes for each forwarded attribute:
shadowrootariaactivedescendanttarget
shadowrootariacontrolstarget
shadowrootariadescribedbytarget
shadowrootariadetailstarget
shadowrootariaerrormessagetarget
shadowrootariaflowtotarget
shadowrootarialabelledbytarget
shadowrootariaownstarget
shadowrootfortarget
shadowrootformtarget
shadowrootlisttarget
shadowrootpopovertargettarget
shadowrootinvoketargettarget
shadowrootinteresttargettarget
shadowrootheaderstarget
shadowrootitemreftarget
shadowrootreferencetarget
-- all other references except the ones specified above
Reflected by JavaScript attributes ShadowRoot.ariaActiveDescendantTarget
, etc.
- Syntax is more in line with other HTML attributes, rather than using a comma-separated list of colon-separated map entries.
- Works with IDs that contain commas.
- It is possible to scope support for properties where this behavior has a real use-case, such as
aria-activedescendant
. This would limit the number of new properties to only a handful.
- Adds 15+ new attributes instead of 2.
- Less clear(?) that
shadowrootreferencetarget
only forwards references that are not explicitly specified by other elements.
The Cross-root ARIA Reflection API explainer proposes adding attributes to elements inside the shadow tree:
<x-foo id="foo">
<template shadowroot="open" shadowrootreflectscontrols shadowrootreflectsariaactivedescendent>
<ul reflectariacontrols>
<li>Item 1</li>
<li reflectariaactivedescendent>Item 2</li>
<li>Item 3</li>
</ul>
</template>
</x-foo>
- Does not require an ID on the target element. [But does still require an extra attribute; possibly in addition to an ID if that ID is used for other purposes.]
- Requires new attributes in two places in order to work: E.g.
shadowrootreflectscontrols
on the shadow root andreflectariacontrols
on the target element. - When multiple elements are used for the same attribute, the author cannot control the order (the order is always the DOM order).
The ExportID explainer proposes a way to refer to elements inside the shadow DOM by name. For example, "fancy-input::id(real-input)"
to refer to a specific <input>
element inside a <fancy-input>
.
It would be possible to use exported IDs instead of referenceTargetMap
if/when it is necessary to refer to an element other than the primary reference target.
- Does not suffer from the bottleneck effect.
- Potentially less confusing why you reference the container listbox with
aria-activedescendant
instead of the element itself.
- Exposes some of the internal details of a control and does not give a way for the control to encapsulate those details.
- This may not be a dealbreaker: the
::part()
CSS selector also has a similar drawback for CSS styles, but it still is a standard and a useful feature for styles.
- This may not be a dealbreaker: the
- Incompatible with ARIAMixin attributes, which don't allow directly referencing elements inside other shadow trees.
- It may be possible to work around this limitation, but it would require a change to the behavior of the ARIAMixin attributes, as well as new JavaScript APIs to resolve an IDREF like
"fancy-input::id(real-input)"
into an "ElementHandle" type object that references the element without giving full access to it (which would break shadow DOM encapsulation).
- It may be possible to work around this limitation, but it would require a change to the behavior of the ARIAMixin attributes, as well as new JavaScript APIs to resolve an IDREF like
It is technically possible to require all attributes to be individually targeted via referenceTargetMap
, rather than also allowing referenceTarget
as a "catch-all" for every attribute.
- The main argument to omit
referenceTarget
is that the semantics could change if more targeted attributes are added in the future. This could break existing websites by changing the target of an attribute, if it is added toreferenceTarget
support in the future. - It makes it more difficult for browser vendors to incrementally implement reference target, since adding support for additional attributes is a breaking change.
- The Reference Target feature is intended to support all attributes that use ID references. Thus, the only time a new attribute will be supported by Reference Target is when it is a completely new attribute in the HTML spec. There is no backwards compatibility concern, since no websites will be using the new attribute before is is supported.
- It is beneficial that this feature automatically supports future attributes added to the HTML spec. It will not require any developer work to update to support new features.
- Including an easy-to-use catch-all attribute supports the HTML design principle of Priority of Constituencies. It priorities users of the feature, over browser implementors and theoretical concerns.
This "kitchen sink" example implements a <fancy-combobox>
using two components: <fancy-input>
and <fancy-listbox>
. It demonstrates:
- Delegating references through multiple layers of shadow DOM.
- A label in the light DOM refers to the
<input>
inside the<fancy-input>
, which is itself inside the<fancy-combobox>
.
- A label in the light DOM refers to the
- Referring to an element in a sibling shadow tree.
- Uses
ariaActiveDescendantElement
in<fancy-input>
along withreferenceTargetMap
in<fancy-listbox>
to connect the<input>
with a<div role="option">
.
- Uses
- Using a custom prop to control the target of
referenceTargetMap
.<fancy-listbox>
allows the target of itsaria-activedescendant
to be controlled externally via its customactiveitem
attribute.
This component is a wrapper around an <input>
, similar to the one in the examples above with a few additional features.
- It sets the input as the reference target. This lets, for example, a label for this component to be applied to the input.
- A custom attribute
listbox
is hooked up to bothariaControlsElements
andariaActiveDescendantElement
.- The listbox targets the two attributes to different elements inside (see
<fancy-listbox>
below), but this component references the parent listbox for both.
- The listbox targets the two attributes to different elements inside (see
- It observes the
role
attribute to set therole
of the internal input.
customElements.define(
"fancy-input",
class FancyInput extends HTMLElement {
static observedAttributes = ["role", "listbox"];
constructor() {
super();
this.shadowRoot_ = this.attachShadow({
mode: "closed",
referenceTarget: "real-input",
});
this.shadowRoot_.innerHTML = `<input id="real-input">`;
this.input_ = this.shadowRoot_.getElementById("real-input");
}
attributeChangedCallback(attr, _oldValue, value) {
if (attr === "listbox") {
// (2)
// Note: A real implementation will need to use connectedCallback and
// MutationObserver to correctly set the listbox. This is just an
// example of how ariaControlsElements might be updated.
const listbox = value ? this.getRootNode().getElementById(value) : null;
this.input_.ariaControlsElements = listbox ? [listbox] : null;
this.input_.ariaActiveDescendantElement = listbox;
} else if (attr === "role" && value !== "none") {
// (3)
this.input_.role = value;
this.role = "none"; // Remove the role from the host
}
}
}
);
This component is a wrapper around <div role="listbox">
and the <div role="option">
items inside.
- It sets
<div role="listbox">
as the reference target for all references exceptaria-activedescendant
. - It has a custom attribute
activeitem
, which is used to control which item gets thearia-activedescendant
delegation usingreferenceTargetMap
. This lets the parent component control the active item.
customElements.define("fancy-listbox",
class FancyListbox extends HTMLElement {
static observedAttributes = ["activeitem"];
constructor() {
super();
this.shadowRoot_ = this.attachShadow({
mode: "closed",
referenceTarget: "real-listbox", // (1)
});
this.shadowRoot_.innerHTML = `
<div id="real-listbox" role="listbox">
<div id="option-1" role="option">Option 1</div>
<div id="option-2" role="option">Option 2</div>
</div>
`;
}
attributeChangedCallback(attr, _oldValue, value) {
if (attr === "activeitem") {
this.shadowRoot_.referenceTargetMap.ariaActiveDescendant = value; // (2)
}
}
});
</script>
This component combines the two components above into a combobox.
- It hooks up the listbox to the input using the
<fancy-input>
's customlistbox
attribute. - It controls which item inside the listbox is the
aria-activedescendant
of the input using the<fancy-listbox>
's customactiveitem
attribute. - It forwards all references to the
"combo-input"
component inside, which itself forwards references to the"real-input"
inside. - Using a label's
for
attribute with the fancy-combobox pierces two layers of shadow DOM to apply the label to the<input id="real-input">
.
<label for="combobox">Combobox</label>
<fancy-combobox id="combobox">
<template
shadowrootmode="closed"
shadowrootreferencetarget="combo-input"
>
<!-- (3) -->
<div>
<!-- (1) -->
<fancy-input id="combo-input" role="combobox" listbox="combo-listbox"></fancy-input>
<!-- (2) -->
<fancy-listbox id="combo-listbox" activeitem="option-1"></fancy-listbox>
</div>
</template>
</fancy-combobox>