Skip to content

Latest commit

 

History

History
690 lines (598 loc) · 26.6 KB

cross-root-aria-reflection.md

File metadata and controls

690 lines (598 loc) · 26.6 KB

Cross-root ARIA Reflection API explainer

It is critically important that content on the web be accessible. When native elements are not the solution, ARIA is the standard which allows us to describe accessible relationships among elements. Unfortunately, it's mechanism for this is historically based on IDREFs, which cannot express relationships between different DOM trees, thus creating a problem for applying these relationships across a ShadowRoot.

Today, because of that, authors are left with only incomplete and undesirable choices:

  • Observe and move ARIA-related attributes across elements (for role, etc.).
  • Use non-standard attributes for ARIA features, in order to apply them to elements in a shadow root.
  • RequirE usage of custom elements to wrap/slot elements so that ARIA attributes can be placed directly on them. This gets very complicated as the number of slotted inputs and levels of shadow root nesting increase.
  • Duplicating nodes across shadow root boundaries.
  • Abandoning Shadow DOM.
  • Abdandoning accessibility.

It is important that this be addressed and authors be able to establish, enable and manage important relationships.

This proposal introduces a reflection API which would allow ARIA attributes and properties set on elements in a shadow root can be reflected by their host element into the parent DOM tree..

This mechanism will allow users to apply standard best practices for ARIA and resolve a large margin of accessibility use cases for applications of native Web components and native Shadow DOM. This API is most suited for one-to-one delegation, but should also work for one-to-many scenarios. There is no mechanism for directly relating two elements in different shadowroots together, but this will still be possible manually with the element reflection API.

The proposed extension adds a new reflects* (e.g.: reflectsAriaLabel, reflectsAriaDescribedBy) options to the .attachShadow method similarly to the delegatesFocus, while introducing a new content attribute auto* (e.g.: reflectarialabel, reflectariadescribedby) to be used in the shadowroot inner elements. This has an advantage that it works with Declarative Shadow DOM as well (though, it requires another set of HTML attributes in the declarative shadow root template), and it is consistent with delegatesFocus. The declarative form works better with common developer paradigm where they may not necessarily have access to a DOM node right where they are creating / declaring it.

<input aria-controlls="foo" aria-activedescendent="foo">Description!</span>
<template id="template1">
  <ul reflectariacontrols>
    <li>Item 1</li>
    <li reflectariaactivedescendent>Item 2</li>
    <li>Item 3</li>
  </ul>
</template>
<x-foo id="foo"></x-foo>
const template = document.getElementById('template1');

class XFoo extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open", reflectsAriaControls: true, reflectsAriaActivedescendent: true });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}
customElements.define("x-foo", XFoo);

In the example above, x-foo would be able to play the role of both the aria-controls element and the aria-activedescendent element for the input, setting that basis for a combo box style interface.

For instance when reflecting aria-activeelement it is desirable that readers know that it applies to the input so that focus does not need to be thrown to a different element when updating the active element. Current workarounds include copying the DOM and referencing something in the same DOM tree in order to complete the reader relationship, while placing that content invisibly in the same place as the content that a pointer device user might leverage to interact with the same data.

Live example: TBD

Usage with Declarative Shadow DOM

This extension allows usage of attributes with Declarative Shadow DOM and won't block SSR.

Following the same rules to add declarative options from attachShadow such as delegateFocus, we should expect ARIA delegation to be used as:

<input aria-controlls="foo" aria-activedescendent="foo">Description!</span>
<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>

In the example above, the <template shadowroot> tag has the content attributes shadowrootreflectscontrols and reflectsariaactivedescendent, matching the options in the imperative attachShadow:

this.attachShadow({ mode: "open", delegatesAriaLabel: true, reflectsAriaControls: true, reflectsAriaActivedescendent: true });

This mirrors the usage of delegatesFocus as:

<template shadowroot="open" shadowrootdelegatesfocus>

being the equivalent of:

this.attachShadow({ mode: "open", delegatesFocus: true });

For now, consistency is being preserved, but otherwise the ARIA delegation attributes can be simplified as:

<span id="foo">Description!</span>
<x-foo aria-label="Hello!" aria-describedby="foo">
  <template shadowroot="open" reflectscontrols reflectsariaactivedescendent>
    <input id="input" autoarialabel autoariadescribedby />
    <span autoarialabel>Another target</span>
  </template>
</x-foo>

Or even further by accepting a token list on reflects or a similarly shaped attribute:

<span id="foo">Description!</span>
<x-foo aria-label="Hello!" aria-describedby="foo">
  <template shadowroot="open" reflects="controls aria-activedescendent">
    <input id="input" autoarialabel autoariadescribedby />
    <span autoarialabel>Another target</span>
  </template>
</x-foo>

Examples

Unlocking the Combobox

Take this simplified "Editable Combobox With List Autocomplete Example" from the ARIA Authoring Practices Guide.

<label for="cb1-input">State</label>
<div class="combobox combobox-list">
    <div class="group">
        <input
            id="cb1-input"
            class="cb_edit"
            type="text"
            role="combobox"
            aria-autocomplete="list"
            aria-expanded="true"
            aria-controls="cb1-listbox"
            aria-activedescendant="lb1-ak"
        />
        <button
            id="cb1-button"
            tabindex="-1"
            aria-label="States"
            aria-expanded="true"
            aria-controls="cb1-listbox"
        ></button>
    </div>
    <ul id="cb1-listbox" role="listbox" aria-label="States">
        <li id="lb1-al" role="option">Alabama</li>
        <li id="lb1-ak" role="option">Alaska</li>
    </ul>
</div>

Currently, to fully achieve the relationships outlined therein, if you wanted to convert this DOM to custom elements, you'd really only have the option to decorate the example:

<x-label>
    <label for="cb1-input">State</label>
</x-label>
<x-combobox>
    <x-input-group>
        <x-input>
            <input
                id="cb1-input"
                class="cb_edit"
                type="text"
                role="combobox"
                aria-autocomplete="list"
                aria-expanded="true"
                aria-controls="cb1-listbox"
                aria-activedescendant="lb1-ak"
            />
        </x-input>
        <x-button>
            <button
                id="cb1-button"
                tabindex="-1"
                aria-label="States"
                aria-expanded="true"
                aria-controls="cb1-listbox"
            ></button>
        </x-button>
    </x-input-group>
    <x-listbox>
        <ul id="cb1-listbox" role="listbox" aria-label="States">
            <li id="lb1-al" role="option">Alabama</li>
            <li id="lb1-ak" role="option">Alaska</li>
        </ul>
    </x-listbox>
</x-combobox>

While this offers some additional custom element-based control over the styles attributed to this UI, the approach continues to hoist the responsibility of building this DOM to the parent component or application.

When moving that DOM management responsibility to the individual custom elements themselves, the responsibility of the consuming developer begins to diminish, but the ID based relationships begin to change or become impossible. To support this, we've assumed the presence of the ARIA Attribute Delegation API in following code examples:

<x-label id="cb1-label">State</x-label>
<x-combobox>
    <x-input-group>
        <x-input
            aria-labeledby="cb1-label"
            role="combobox"
            aria-autocomplete="list"
            aria-expanded="true"
            aria-controls="cb1-listbox"
            aria-activedescendant="lb1-ak"
        >
            #shadow-root delegates="aria-labeledby role aria-autocomplete aria-expanded aria-controls aria-activedescendant"
                <input
                    type="text"
                    auto-role
                    auto-aria-labeledby
                    auto-aria-autocomplete
                    auto-aria-expanded
                    auto-aria-controls
                    auto-aria-activedescendant
                />
        </x-input>
        <x-button
            aria-labeledby="cb1-label"
            tabindex="-1"
            aria-expanded="true"
            aria-controls="cb1-listbox"
        ></x-button>
    </x-input-group>
    <x-listbox
        aria-labeledby="cb1-label"
        options='[["Alabama", "lb1-al"], ["Alaska", "lb1-ak"]]'
    ></x-listbox>
</x-combobox>

Here we've moved from the for attribute on a <label> element to giving its host an ID so that other elements can reference it via aria-labelledby. This persists the accessible relationship, but it removes the previously managed interactions, like clicking the <label> focusing on the input. We also see the aria-activedescendant relationship broken as the ID lb1-ak moves into the shadow root of the <x-listbox> element. This is the first place the ARIA Attribute Reflection benefits the refactor of the pattern from raw DOM to custom elements.

<x-label id="cb1-label">State</x-label>
<x-combobox>
    <x-input-group>
        <x-input
            aria-labeledby="cb1-label"
            role="combobox"
            aria-autocomplete="list"
            aria-expanded="true"
            aria-controls="cb1-listbox"
            aria-activedescendant="lb1-ak"
        >
            #shadow-root delegates="aria-labeledby role aria-autocomplete aria-expanded aria-controls aria-activedescendant"
                <input
                    type="text"
                    auto-role
                    auto-aria-labeledby
                    auto-aria-autocomplete
                    auto-aria-expanded
                    auto-aria-controls
                    auto-aria-activedescendant
                />
        </x-input>
        <x-button
            aria-labeledby="cb1-label"
            tabindex="-1"
            aria-expanded="true"
            aria-controls="cb1-listbox"
        >
            #shadow-root delegates="aria-expanded aria-controls aria-label"
                <button auto-aria-expanded auto-aria-controls auto-aria-label></button>
        </x-button>
    </x-input-group>
    <x-listbox
        aria-labeledby="cb1-label"
        id="cb1-listbox"
        options='["Alabama", "Alaska"]'
    >
        #shadow-root delegates="label" reflects="aria-activedescendant role"
            <ul role="listbox" reflect-role autolabel>
                <li role="option">Alabama</li>
                <li role="option" reflect-aria-activedescendant>Alaska</li>
            </ul>
    </x-listbox>
</x-combobox>

As we begin to see the benefits and capabilities that the Delegation and Reflection APIs open for out custom element architectures, this example can be further simplified.

<x-label for="cb1">
    #shadow-root delegates="for"
        <label autofor><slot></slot></label>
    State
</x-label>
<x-combobox
    id="cb1"
    options='["Alabama", "Alaska"]'
>
    #shadow-root delegates="focus label"
        <x-input
            aria-labeledby="cb1-label"
            role="combobox"
            aria-autocomplete="list"
            aria-expanded="true"
            aria-controls="listbox"
            aria-activedescendant="listbox"
        >
            #shadow-root delegates="aria-labeledby role aria-autocomplete aria-expanded aria-controls aria-activedescendant"
                <input
                    type="text"
                    auto-role
                    auto-aria-labeledby
                    auto-aria-autocomplete
                    auto-aria-expanded
                    auto-aria-controls
                    auto-aria-activedescendant
                />
        </x-input>
        <x-button
            autolabel
            tabindex="-1"
            aria-expanded="true"
            aria-controls="listbox"
        >
            #shadow-root reflects="role"
                <button reflect-role></button>
        </x-button>
        <x-listbox
            autolabel
            id="listbox"
            options=options
        >
            #shadow-root delegates="label" reflects="aria-activedescendant role"
                <ul role="listbox" reflect-role autolabel>
                    <li role="option">Alabama</li>
                    <li role="option" reflect-aria-activedescendant>Alaska</li>
                </ul>
        </x-listbox>
</x-combobox>

In the above example, the move to leveraging a shadow root on <x-combobox> even opened the one to many relationship of the autolabel delegation allowing for the return to the for attribute, which is delegated to an actual <label> element to surface the interaction relationships we had previously lost with the move to aria-labelledby. All the while, we've reduced the DOM that a consumer of this pattern is required to write to:

<x-label for="cb1">State</x-label>
<x-combobox
    id="cb1"
    options='["Alabama", "Alaska"]'
></x-combobox>

This feels like a really nice refactor. All of these changes are powered by highly useful API in the form of ARIA Attribute Delegation and ARIA Attribute Reflection and were previously not possible when placing shadow boundaries between important content in an interface. However, it does assume a greenfield implementation. A more realistic look at what these APIs can surface will be derived from decorating or composing existing patterns into these complex interfaces.

Take this interpretation of a popular custom elements library's "input" and "list" components:

<y-textfield
    label="State"
>
    #shadow-root
        <label>
            ${label}
            <input />
        </label>
</y-textfield>
<y-button></y-button>
<y-list>
    <y-list-item>Alabama</y-list-item>
    <y-list-item>Alaska</y-list-item>
</y-list>

Let's see how we might be able to update this example with the ARIA Delegation and Reflection APIs in order to complete the Combobox contract for screen readers.

<y-textfield
    label="State"
    id="cb2-textfield"
    role="combobox"
    aria-autocomplete="list"
    aria-expanded="true"
    aria-controls="cb2-listbox"
    aria-activedescendant="lb2-ak"
>
    #shadow-root delegates="role aria-autocomplete aria-expanded aria-controls aria-activedescendant" reflects="label"
        <label reflects-label>
            ${label}
            <input
                auto-role
                auto-aria-labeledby
                auto-aria-autocomplete
                auto-aria-expanded
                auto-aria-controls
                auto-aria-activedescendant
            />
        </label>
</y-textfield>
<y-button
    aria-labelledby="cb2-textfield"
    aria-expanded="true"
    aria-controls="cb2-listbox"
    icon="expand_more"
>
    #shadow-root delegates="label aria-expanded aria-controls"
        <button auto-label auto-aria-expanded auto-aria-controls>
            <y-icon>expand_more</y-icon>
        </button>
</y-button>
<y-list
    aria-labelledby="cb2-textfield"
    id="cb2-listbox"
>
    <y-list-item id="lb2-al">Alabama</y-list-item>
    <y-list-item id="lb2-ak">Alaska</y-list-item>
</y-list>

This places a pretty high burden on consumers:

<y-textfield
    label="State"
    id="cb2-textfield"
    role="combobox"
    aria-autocomplete="list"
    aria-expanded="true"
    aria-controls="cb2-listbox"
    aria-activedescendant="lb2-ak"
></y-textfield>
<y-button
    aria-labelledby="cb2-textfield"
    aria-expanded="true"
    aria-controls="cb2-listbox"
    icon="expand_more"
></y-button>
<y-list
    aria-labelledby="cb2-textfield"
    id="cb2-listbox"
>
    <y-list-item id="lb2-al">Alabama</y-list-item>
    <y-list-item id="lb2-ak">Alaska</y-list-item>
</y-list>

However, it could be easily composed into a single shadow root:

<y-combobox
    label="state"
    aria-activedescendant="lb2-ak"
>
    #shadow-root delegates="aria-activedescendant"
        <y-textfield
            label=label
            id="cb2-textfield"
            role="combobox"
            aria-autocomplete="list"
            aria-expanded="true"
            aria-controls="cb2-listbox"
            auto-aria-activedescendant
        ></y-textfield>
        <y-button
            aria-labelledby="cb2-textfield"
            aria-expanded="true"
            aria-controls="cb2-listbox"
            icon="expand_more"
        ></y-button>
        <y-list
            aria-labelledby="cb2-textfield"
            id="cb2-listbox"
        >
            <slot name="items"></slot>
        </y-list>
    <y-list-item id="lb2-al" slot="items">Alabama</y-list-item>
    <y-list-item id="lb2-ak" slot="items">Alaska</y-list-item>
</y-combobox>

This comes out to the following for a consuming developer:

<y-combobox
    label="state"
>
    <y-list-item id="lb2-al" slot="items">Alabama</y-list-item>
    <y-list-item id="lb2-ak" slot="items">Alaska</y-list-item>
</y-combobox>

Even less if you choose to encapsulate DOM management for the list items by accepting an array of items the way our <x-combobox> did above. However, in both cases the ARIA Attribute Delegation and Reflection APIs are making it possible for more complex interfaces to be accessible when built with custom element and shadow DOM without needing to architect your whole implementation around the intricacies of keeping ID references in a single DOM tree.

Other patterns

There are many patterns where the presence of a shadow boundary will prevent otherwise default relationships between DOM element. Another that this API could directly benefit is a button group that manages selection on those buttons similar to what we see in a collection of radio buttons (one selected button) or checkboxes (multiple selected buttons).

Generally, this contract is made by having a role="radiogroup" or role="group" parent gather a collection of role="radio" or role="checkbox" elements, respectively. In this case the native semantics and focusability of a <button> element can do a lot of the heavy lifting, but if you do do inside of a shadow boundary you run into some problems:

<z-button-group role="radiogroup">
    <z-button role="radio">
        #shadow-root
            <button><slot></slot></button>
        Option 1
    </z-button>
    <z-button role="radio">
        #shadow-root
            <button><slot></slot></button>
        Option 2
    </z-button>
</z-button-group>

Here the role="radio" elements share a DOM tree with the role="radiogroup" elements to fulfill the contract requried to build the correct accessibility tree and pass it on to screen readers. However, the <button> elements internal to the <z-button> custom elements also take a place in the accessibility tree confusing the pattern. Often custom element developers will either need to pass the role attribute synthetically, which means the "radio" elements is no longer in the same DOM tree and the "radiogroup" element, or forgo the native <button> element and the benefits of leveraging it directly, like native tabindex management, etc. Here is another useful place for us to instead do a combination of delegating and reflecting aria attribtues from the shadow DOM into the parent DOM tree:

<z-button-group role="radiogroup">
    <z-button role="radio">
        #shadow-root delegates="focus role" reflects="role"
            <button auto-role reflect-role><slot></slot></button>
        Option 1
    </z-button>
    <z-button role="radio">
        #shadow-root delegates="focus role" reflects="role"
            <button auto-role reflect-role><slot></slot></button>
        Option 2
    </z-button>
</z-button-group>

In this way, while somewhat convoluted, the <z-button> elements will appear as a "radio" element in the DOM tree of the <z-button-group> element, fulfilling the accessibility tree contract, while the <button> will be the actual element that responsibility allow for native surfacing of other accessible semantics, behaviors, etc.


Appendix

Attribute Reflection and Aria Reflection proximity

The Aria Reflection API has already made a name for itself in the Aria community for the new capabilities that it unlocks. There is a possibility that an ARIA Attribute Reflection API would be confusing when places next to it. With that in mind, possible alternative may include:

  • ARIA Attribute Export API
    • This would update reflects="..." to exports="..." and reflect-* to export-*, etc.
  • ARIA Attribute Hoisting API
    • This would update reflects="..." to hoists="..." and reflect-* to hoist-*, etc.
  • ARIA Attribute Surfacing API
    • This would update reflects="..." to surfaces="..." and reflect-* to surface-*, etc.
  • ARIA Attribute Maping API
    • Possibly becomes a parent API naming to include both this and the delegation API
    • This doesn't give a specific direction for attribute naming, but might be worth concidering at large.
    • Is it just an "Attribute" Mapping API, despecializing it for ARIA...

No silver bullet

Not all cross-shadow use cases are covered. Cases like radio groups, tab groups, or combobox might require complexity that is not available yet at this current cross-root delegation API. Though custom versions of this might be possible where they weren't before, like building a custom radio group with the <input> inside of a shadow root:

<div role="radiogroup">
  <x-radio>
    <template shadowroot="open" reflects="role aria-checked">
      <input type="radio" reflectrole reflectariachecked />
    </template>
  </x-radio>
</div>

The reflection API might also not resolve attributions from multiple shadow roots in parallel or attributes that would point to DOM trees containing the current host component.

Thoughts: attribute names are too long

The attributes names such as shadowrootreflectss* are very long and some consideration for shorter names by removing the shadowroot prefix can be discussed as long the discussion is sync'ed with the stakeholders of the respective Declarative Shadow DOM proposal. This can be further shortened by taking the reflects-attributes collection as a DOM token list on a single attribue.

  • Can the various spec bodies come into agreement that using - in the attribute names will make them easier to spell/use. reflect-* instead of reflect* and reflect-aria-autocomplete instead of reflectariaautocomplete, etc.?
  • Could this be paired with a source element attribute that could surface the concepts that an single element in the shadow root reflects as a group. That way in the following example reflect-attributes="role aria-checked" could be leveraged instead of both reflectrole and reflect-aria-checked:
    <div role="radiogroup">
      <x-radio>
        <template shadowroot="open" reflects-attributes="role aria-checked">
          <input type="radio" reflect-attributes="role aria-checked" />
        </template>
      </x-radio>
    </div>

Non-"aria" attributes for consideration

datalist

Leveraging the datalist attribute on an encapsulated <input> element requires that support for this feature is built into the same DOM tree. Support for reflection of this attribute would free this feature to be leveraged more widely:

<label for="ice-cream-choice">Choose a flavor:</label>
<d-input list="ice-cream-flavors" id="ice-cream-choice">
  <template shadowroot="open" delegates="list label">
    <input auto-list auto-label name="ice-cream-choice" />
  </template>
</d-input>

<d-datalist id="ice-cream-flavors">
  <template shadowroot="open" reflects="list">
    <datalist reflect-list>
      <option value="Chocolate">
      <option value="Coconut">
      <option value="Mint">
      <option value="Strawberry">
      <option value="Vanilla">
    </datalist>
  </template>
</d-datalist>

OpenUICG Popup API

The OpenUICG is developing an API to support content that popsup over a page. In thier example of using the new attributes in shadow DOM the element owning the popup attribute is encapsulated in a shadow root to protect it from the surrounding application context. This means that the relationship required for a togglepopup, showpopup, or hidepopup bearing element can no longer be made from that level:

<my-tooltip>
    <template shadowroot=closed>
      <div popup=hint>This is a tooltip: <slot></slot></div>
    </template>
    Tooltip text here!
  </my-tooltip>

This could be addressed by reflecting the popup element to the host like so:

<button togglepopup=foo>Toggle the pop-up</button>
<my-tooltip id="foo">
    <template shadowroot=closed reflects="popup">
        <div popup=hint reflect-popup>This is a tooltip: <slot></slot></div>
    </template>
    Tooltip text here!
</my-tooltip>

Here, the declarative relationship between the [togglepopup] element and the [popup] elements can be made without surfacing the .showPopUp() method directly on the <my-tooltip> container.

Public summary from WCCG

https://w3c.github.io/webcomponents-cg/#cross-root-aria

GitHub Issue(s):