Skip to content

Latest commit

 

History

History
553 lines (424 loc) · 18 KB

element_reference_properties.md

File metadata and controls

553 lines (424 loc) · 18 KB

Element reference DOM properties

Introduction

Element reference DOM properties will allow you to create associations between elements, like ID reference attributes, but using the element reference directly.

For example, if you had this structure:

<label>
  Phone number:
  <input type="tel">
</label>
<span id="description">Please include international country code, e.g. +61</span>

You could associate the input with the description using the DOM API:

const input = document.querySelector('input');
const description = document.getElementById('description');

input.ariaDescribedByElements = [description];

This is a simplified example, but this allows complex, dynamic interfaces to use attributes like aria-activedescendant and aria-owns to convey the current state without complex book-keeping of unique IDs:

tree.addEventListener('keydown', (event) => {
  // Extremely simplified example code, don't copy this!
  switch (event.code) {
    case "ArrowUp":
      const previous = tree.getPreviousItem(tree.selectedItem);

      // No need to generate a unique ID to use aria-activedescendant
      tree.ariaActiveDescendantElement = previous;

      previous.ariaSelected = true;
      tree.selectedItem.ariaSelected = false;
      tree.selectedItem = previous;
      break;

    // etc.
  }
});

Key use cases

This API addresses two main use cases:

This API also generally reduces the amount of boilerplate code needed to use ARIA attributes like aria-activedescendant, which may change dynamically as the user interacts with the application. This makes it more ergonomic to use these attributes to begin with, which might encourage more use.

Note on Element array property equality

Because Element array properties like ariaLabelledByElements are computed each time they are retrieved, in order to check each element is still valid, the property value will appear not to be equal to itself:

input.ariaLabelledByElements === input.ariaLabelledByElements;  // false

Explicit setter and getter alternative

If lack of self-equality disqualifies a property-based API, we may need to re-design the API to use explicit getters and setters, for example:

<label>
  Phone number:
  <input type="tel">
</label>
<span id="description">Please include international country code, e.g. +61</span>
const input = document.querySelector('input');
const description = document.getElementById('description');

input.setAriaDescribedByElements([description]);

console.log(input.getAriaDescribedByElements());

The remainder of this explainer assumes a property-based API. If the API needs to be re-designed, some of the design decisions explained below may need to be re-considered.

Single vs. multiple element properties

The first example above uses ariaDescribedByElements, which corresponds to aria-describedby. aria-describedby takes a list of space-separated IDs (an ID reference list), to refer to one or more Elements.

To allow the DOM property to express this, it always takes and returns an Array of Elements (and the name includes "Elements", plural, to reflect this).

The second example uses ariaActiveDescendantElement, which corresponds to aria-activedescendant.

aria-activedescendant takes a single ID reference, so ariaActiveDescendantElement takes and returns either a single Element reference, or null. The name includes "Element", singular, to reflect this.

Reflection to HTML attributes

Element reference properties will reflect to their corresponding HTML attributes.

For example, in the simple form above:

<label>
  Phone number:
  <input type="tel">
</label>
<span id="description">Please include international country code, e.g. +61</span>

After setting the ariaDescribedByElements property:

input.ariaDescribedByElements = [description];

The live HTML structure will now reflect this:

<label>
  Phone number:
  <input type="tel" aria-describedby="description">
</label>
<span id="description">Please include international country code, e.g. +61</span>

That is, if the element being referred to does have an ID, it will set the attribute to the same value you'd use to create an IDREF association.

If the referenced element doesn't have an ID, or it isn't in the same ID scope as the host element, then the HTML attribute will have an empty string value:

<label>
  Phone number:
  <input type="tel" aria-describedby>
</label>
<span>Please include international country code, e.g. +61</span>

In either case, removing the HTML attribute will also clear the DOM property:

input.removeAttribute('aria-describedby');
console.log(input.ariaDescribedByElements);  // null

Computing element references from HTML attributes

If you set the HTML attribute directly, whether via setAttribute or directly in the HTML source, the corresponding DOM property will be computed at the time it is accessed:

<ul id="radiogroup" role="radiogroup" aria-activedescendant="item-1" tabindex="0">
  <li role="radio" aria-checked="true" id="item-1">Item #1</li>
  <li role="radio" aria-checked="false" id="item-2">Item #2</li>
  <li role="radio" aria-checked="false" id="item-3">Item #3</li>
</ul>
console.log(radiogroup.getAttribute('aria-activedescendant'));    // logs "item-1"
console.log(radiogroup.ariaActiveDescendantElement.textContent);  // logs "Item #1"

If the HTML attribute isn't present, the corresponding DOM property will be null (for both single and multiple Element properties):

const radio1 = document.getElementById('item-1');
console.log(radio1.ariaActiveDescendantElement);  // logs null
console.log(radio1.ariaLabelledByElements);       // logs null

If the HTML attribute is present, but doesn't contain any valid IDs, and the DOM property wasn't set directly, then the DOM property will be null for a single Element property, or an empty Array for a multiple Element property:

<li id="item" role="radio" aria-activedescendant="invalid" aria-labelledby="invalid">Item X</li>
const item = document.getElementById('item');
console.log(item.ariaActiveDescendantElement);  // logs null
console.log(item.ariaLabelledByElements);       // logs []

Warning on potentially confusing property getter behaviour

Warning: The value returned from the DOM property getter can occasionally depend on how it was set.

For example, if you started with this structure:

<ul id="radiogroup" role="radiogroup" aria-activedescendant="item-2" tabindex="0">
  <li role="radio" aria-checked="true" id="item-1">Item #1</li>
  <li role="radio" aria-checked="false" id="item-2">Item #2</li>  <!-- active descedant -->
  <li role="radio" aria-checked="false" id="item-3">Item #3</li>
</ul>

And then changed the ID of the first radio to be the same as the second:

const firstOption = radiogroup.firstElementChild;
firstOption.id = "item-2";
<ul id="radiogroup" role="radiogroup" aria-activedescendant="item-2" tabindex="0">
  <li role="radio" aria-checked="false" id="item-2">Item #1</li>  <!-- same ID as active descendant -->
  <li role="radio" aria-checked="true" id="item-2">Item #2</li>   <!-- active descendant? -->
  <li role="radio" aria-checked="false" id="item-3">Item #3</li>
</ul>

Then, if you had set the aria-activedescendant attribute directly (either using setAttribute(), or directly in the HTML source), this would be the result:

console.log(radiogroup.getAttribute('aria-activedescendant'));    // logs "item-2"
console.log(radiogroup.ariaActiveDescendantElement.textContent);  // logs "Item #1"

However, if you had set the second <li> as the ariaActiveDescendantElement property, that would also have set the aria-activedescendant attribute to "item-2", but if you changed the IDs after that, the ariaActiveDescendantProperty will still return the second <li>:

console.log(radiogroup.getAttribute('aria-activedescendant'));    // logs "item-2"
console.log(radiogroup.ariaActiveDescendantElement.textContent);  // logs "Item #2"

To avoid this confusion, follow HTML best practices:

  • avoid changing IDs, and
  • avoid having multiple elements with the same ID.

Element reference properties and Shadow DOM

Unlike ID reference attributes, element reference properties can refer to elements across Shadow DOM boundaries (with restrictions).

If you wanted to create a custom combobox, which contains an <input> element inside shadow DOM, and autocomplete options which are provided via a <slot>, you might end up with a structure something like this:

<custom-combobox>
  #shadow-root (open)
  |  <input>
  |  <slot></slot>
  #/shadow-root
  <custom-optionlist>
    <x-option id="opt1">Option 1</x-option>
    <x-option id="opt2">Option 2</x-option>
    <x-option id='opt3'>Option 3</x-option>
 </custom-optionlist>
</custom-combobox>

You would want to set the currently selected autocomplete option as the aria-activedescendant for the <input>, but an IDREF association won't work because the Shadow Root creates a separate scope for IDs.

However, you can still use the ariaActiveDescendantElement property to create the association:

// (Assume you already have the JS variables set up correctly)
input.ariaActiveDescendantElement = opt1;

console.log(input.ariaActiveDescendantElement.id);          // logs "opt1";

// Since opt1 is in a different scope, the attribute value is empty string
console.log(input.getAttribute('aria-activedescendant')));  // logs "";

Valid vs. invalid references

An element may refer to another element as long as the referenced element is a descendant of any shadow-including ancestor of the host element.

"Shadow-including ancestor" means any element which is an ancestor, taking Shadow DOM into account.

For example, in the <custom-combobox> example above, a reference from the <input> to opt1 is valid: opt1 is a descendant of the <custom-combobox> element, which is a shadow-including ancestor of the <input> element.

However, a reference from opt1 to the <input> element is not valid, since the <input> element isn't a direct ancestor of <custom-combobox>.

If a reference isn't valid, getting the DOM property value won't return the referenced element, but instead will return null for single Element properties, or remove the referenced Element from the Array returned for multiple Element properties.

If you tried:

<custom-combobox>
  #shadow-root (open)
  |  <input>
  |  <slot></slot>
  #/shadow-root
  <custom-optionlist>
    <x-option id="opt1">Option 1</x-option>
    <x-option id="opt2">Option 2</x-option>
    <x-option id='opt3'>Option 3</x-option>
 </custom-optionlist>
</custom-combobox>
// There's literally no reason you'd even think about doing
// any of this, but if you did...
opt1.ariaActiveDescendantElement = input;

console.log(opt1.getAttribute("aria-activedescendant"));  // logs ""
console.log(opt1.ariaActiveDescendantElement);            // logs null

opt1.ariaControlsElements = [input];

console.log(opt1.getAttribute("aria-controls"));  // logs ""
console.log(opt1.ariaControlsElements);           // logs []

... the HTML attribute gets set, but you can't get the <input> element reference from the DOM property.

If you did somehow manage to move the <input> element into the same scope as opt1, you would then see it as the return value of the DOM property:

opt1.parentElement.appendChild(input);

console.log(opt1.ariaControlsElements);         // logs [input];
console.log(opt1.ariaActiveDescendantElement);  // logs input;

Elements not in the DOM tree

If you have an association between a host and a referenced element:

<div id="container">
  <ul id="radiogroup" role="radiogroup" tabindex="0">
    <li role="radio" aria-checked="false" id="item-1">Item #1</li>
    <li role="radio" aria-checked="true" id="item-2">Item #2</li>
    <li role="radio" aria-checked="false" id="item-3">Item #3</li>
  </ul>
</div>
const radiogroup = document.getElementById('radiogroup');
radiogroup.ariaActiveDescendantElement = document.getElementById('item-2');

If you remove either the host element, or the referenced element from the document:

document.getElementById('item2').remove();

... then it's no longer the case that the referenced element is a descendant of a shadow-including ancestor of the host element. Because of this, the ariaActiveDescendantElement property getter will return null:

console.log(radiogroup.ariaActiveDescendantElement);  // logs null

However, if you remove a common ancestor of both elements, then the relationship is still valid:

const container = document.getElementById('container');
container.remove();

console.log(radiogroup.ariaActiveDescendantElement);  // logs null

Invalid references in Element array properties

If an Element array property (corresponding to an ID reference list attribute) contains multiple Elements, the getter will return

For example, if you had a radio group, and wanted to use ariaOwnsElements to modify the order of the radios in the accessibility tree, you could do something like this:

<ul id="radiogroup" role="radiogroup" >
  <li role="radio" aria-checked="true">Item #3</li>
  <li role="radio" aria-checked="false">Item #1</li>
  <li role="radio" aria-checked="false">Item #2</li>
</ul>
const radios = radiogroup.querySelectorAll('li');
radiogroup.ariaOwnsElements = [ radios[2], radios[3], radios[1] ];

for (let radio of radiogroup.ariaOwnsElements) {
 console.log(radio.textContent);  // logs consecutively "Item #1", "Item #2" and "Item #3"
}

If you made the radio Element with text content "Item #2" invalid (i.e. the third radio Element), such as by removing it from the DOM tree, ariaOwnsElements would still return the other two Elements:

radios[3].remove();

for (let radio of radiogroup.ariaOwnsElements) {
 console.log(radio.textContent);  // logs consecutively "Item #1" and "Item #3"
}

Element reference properties and the accessibility tree

Accessibility tree computation is (indirectly) based on the computed value of the relelvant DOM property.

For example, in the radiogroup example above, when ariaOwnsElements is initially set, the accessibility tree would have nodes named "Item #1", "Item #2" and "Item #3", in that order, as the children of the node corresponding to the <ul role="radiogroup"> Element.

After the node with text content "Item #2" is removed from the document, it would only have as children nodes named "Item #1" and "Item #3", in that order.

The accessibility tree computation may take other things into account when determining what to expose, but this doesn't affect what the DOM property getter will return.

For example, if the radio Element with text content "Item #1" had an aria-hidden="true" attribute, it wouldn't be exposed as a child of the radiogroup in the accessibility tree, but it wouldn't change what the ariaOwnsElements property getter would return.

Removing Element reference properties

You can remove an Element reference property two ways:

  1. By removing the HTML attribute, or
  2. By setting the DOM property to null (for both single and multiple Element reference properties).

Even if a property becomes invalid, it won't be removed altogether unless you do one of these two things.

Element reference properties and garbage collection

An Element reference property doesn't cause an Element to be retained by the JavaScript engine's garbage collection process.

For example, if you remove an element from the DOM which is referred to by another element's ariaActiveDescendantElement property, but not referred to in any active JavaScript context, that element may be garbage collected at any point.

This means that not clearing DOM property references to Elements which aren't going to be re-inserted in the DOM (since you would have to have an active reference to them to do that) has no impact on memory usage.