Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content attribute to import/export IDs across shadow boundaries #169

Open
rniwa opened this issue Oct 21, 2020 · 36 comments
Open

Content attribute to import/export IDs across shadow boundaries #169

rniwa opened this issue Oct 21, 2020 · 36 comments

Comments

@rniwa
Copy link

rniwa commented Oct 21, 2020

During AOM sync up on 10/20, we came up with the idea of using content attribute to map ID across shadow boundaries like exportparts.

The idea here is to use innerIdent: outerIdent pairs to denote mapping of inner tree's ID with outer tree'd ID. For example, in the following example, radio_label defined in the shadow tree of my-element is exported as list in the outer tree, and ul is labeled by the div in the outer tree.

<div id="list">My radio label</div>
<my-element aria-maps="radio_label:list">
    #shadow
        <ul role="radiogroup" aria-labelledby="radio_label">
            <li role="radio">Item #1</li>
            <li role="radio">Item #2</li>
            <li role="radio">Item #3</li>
        </ul>
</my-element>

In the following example, we export my_label in the shadow tree in label-element's shadow tree as radio_label and makes use of it by ul in the outer tree.

<label-element aria-maps="my_label:radio_label">
    #shadow
        <div id="my_label">My radio label</div>
</label-element>
<ul role="radiogroup" aria-labelledby="radio_label">
    <li role="radio">Item #1</li>
    <li role="radio">Item #2</li>
    <li role="radio">Item #3</li>
</ul>

Combining these two things together, we can export a label from one shadow tree and use it in another shadow tree:

<label-element aria-maps="my_label:list">
    #shadow
        <div id="my_label">My radio label</div>
</label-element>
<my-element aria-maps="radio_label:list">
    #shadow
        <ul role="radiogroup" aria-labelledby="radio_label">
            <li role="radio">Item #1</li>
            <li role="radio">Item #2</li>
            <li role="radio">Item #3</li>
        </ul>
</my-element>
@alice
Copy link
Member

alice commented Oct 21, 2020

As discussed, this seems more general than ARIA, so the name aria-maps may not be general enough.

@jcsteh
Copy link
Collaborator

jcsteh commented Oct 21, 2020

CC @annevk. This idea was developed in response to the concerns about components depending on the outer tree with element reflection references from inner to outer scopes (whatwg/html#6063).

One question is whether this should actually impact the id map (e.g. getElementById) in the destination scope or whether these "ids" should only be used for the purposes of ARIA. While it would seem to be intuitive that they do impact destination id maps, I'm concerned that would allow outer scopes to mutate and walk the inner tree. Take the second example above (modified slightly for clarity):

<label-element aria-maps="inner_my_label:outer_radio_label">
    #shadow
        <div id="inner_my_label">My radio label</div>
        <div id="inner_sibling">...</div>
</label-element>
<ul role="radiogroup" aria-labelledby="outer_radio_label">
    ...
</ul>

If id maps are affected, the outer scope can now do:
let innerLabel = document.getElementById("outer_radio_label");
They can then mutate innerLabel. Worse, they can walk the inner tree from that point:
let innerSibling = innerLabel.nextSibling;

I'm also not sure how the browser will reliably figure out which are imports and which are exports.

@rniwa
Copy link
Author

rniwa commented Oct 21, 2020

We clearly can't expose IDs in the sense of making getElementById return those elements. I suppose we could consider making <form id="foo"> & <input form=foo> work but then input.form in JS must return null.

@alice
Copy link
Member

alice commented Oct 21, 2020

Similarly <label for=foo>, I assume?

So the ID forwarding mechanism is limited in its impacts.

What if getElementById() returned the element the ID forwarding was declared on?

@rniwa
Copy link
Author

rniwa commented Oct 21, 2020

Similarly <label for=foo>, I assume?

Similarly what?

So the ID forwarding mechanism is limited in its impacts.

Not sure what do you mean by this. We can certainly make <label for=foo> and <input from=foo> work across shadow boundaries as we're proposing for ARIA so they're just as powerful and even more powerful than the JS API in terms of expressibility of ID-able element relationships since it can reference a node inside the shadow tree from outside via forwarding.

What if getElementById() returned the element the ID forwarding was declared on?

I'd imagine there is no change in the behavior of getElementById. It would continue to only work in the same tree and only look for an element with that id. In that regard, forwarding is less powerful than the JS API but perhaps this is more of a benefit if our goal is to maintain more of encapsulation.

I do think it's a bit weird that getElementById would start to disagree with what ARIA and other element ID ref would see though. Perhaps we need to make these functions optionally query any element which forwards IDs from its inner tree.

@web-padawan
Copy link

web-padawan commented Oct 21, 2020

Thanks for the initiative! Let me share a real world use case that we have in our components library.

  1. We have vaadin-text-field component with a <label> in Shadow DOM, and it has id attribute.
  2. User can set label property on the component to provide a text content for <label>:
<vaadin-text-field label="Login"></vaadin-text-field>
  1. However, in some cases we would like to keep internal <label> empty and use an external <label> instead:
<vaadin-form-item>
    <label id="outer">Label</label>
</vaadin-form-item>
<vaadin-text-field aria-maps="label-1:outer">
    #shadow
        <label part="label" id="label-1"></label>
        <input id="input-1" aria-labelledby="label-1">
</vaadin-text-field-element>

I'm wondering how to handle both use cases. So far I see the following options:

  1. Rendering <label> internally only in case if label property is set on the component;
  2. Removing id attribute on the internal <label> when the aria-maps attribute is set;
  3. Changing the aria-labelledby attribute to different id from the one that internal label has.

The question is whether aria-labelledby would still work with either of these approaches?

@alice
Copy link
Member

alice commented Oct 21, 2020

Similarly what?

Oops, that's what happens when I try to comment when exhausted. I mean similarly to <form id="foo">, we would want to be able to forward IDs for <label for="foo">, e.g.

<label for="input">
<custom-combobox aria-maps="input: inner_input">
# shadowRoot
  <input id="inner_input">
</custom-combobox>

So the ID forwarding mechanism is limited in its impacts.

Not sure what do you mean by this.

Just that the ID forwarding only affects declarative attributes, but not script, as you said.

I think it might be worth exploring having script-based APIs return the element that the forwarding is declared on - much like how we return the light DOM ancestor of the active element for document.activeElement.

e.g. for @jcsteh's example:

<label-element aria-maps="inner_my_label:outer_radio_label">
    #shadow
        <div id="inner_my_label">My radio label</div>
        <div id="inner_sibling">...</div>
</label-element>
<ul role="radiogroup" aria-labelledby="outer_radio_label">
    ...
</ul>
console.log($('ul[role=radiogroup]').ariaLabelledByElement);  // logs <label-element>
document.getElementById('inner_my_label');    // same, or maybe we need a forwarding-aware version?

I recall at some point we talked about having a mechanism to pass in a shadow root to some kind of accessor method, but I've forgotten the context. Would some kind of shadow DOM aware accessor be helpful here?

@caridy
Copy link

caridy commented Oct 22, 2020

I'm very excited about this one, thanks @rniwa for taking the time to address this. We raised that concern in #107 more than a year ago about programatic access to elements from another shadow (reflection of id and elements), as it breaks the encapsulation, making it impossible for us to use such feature.

My proposal at that time was similar to this, but obviously this is much more nicer. The declarative aspect of this proposal aligns very well for us, and the parts precedent makes me think that this could work very well. I do not think we need a specific imperative API, developers can simply rely on the DOM apis for attributes, plus the traversal mechanism of a shadow to discover those elements in the shadows are open.

@rniwa
Copy link
Author

rniwa commented Oct 22, 2020

I think it might be worth exploring having script-based APIs return the element that the forwarding is declared on - much like how we return the light DOM ancestor of the active element for document.activeElement.

e.g. for @jcsteh's example:

<label-element aria-maps="inner_my_label:outer_radio_label">
    #shadow
        <div id="inner_my_label">My radio label</div>
        <div id="inner_sibling">...</div>
</label-element>
<ul role="radiogroup" aria-labelledby="outer_radio_label">
    ...
</ul>
console.log($('ul[role=radiogroup]').ariaLabelledByElement);  // logs <label-element>
document.getElementById('inner_my_label');    // same, or maybe we need a forwarding-aware version?

I recall at some point we talked about having a mechanism to pass in a shadow root to some kind of accessor method, but I've forgotten the context. Would some kind of shadow DOM aware accessor be helpful here?

That's an interesting idea. For selection, what we concluded was that we want to make a method take a set of shadow roots and disclose nodes if nodes are either not in any shadow root or is in one of the shadow roots being passed in the argument.

So for the above example, we would have the behavior like this:

document.getElementById('outer_radio_label');
// returns null

document.getElementById('outer_radio_label', {shadowRoots: [shadowRootOfLabelElement]});
// returns the div inside the shadow root

document.querySelector('ul[role=radiogroup]').ariaLabelledByElement;
// returns null

document.querySelector('ul[role=radiogroup]').getAriaLabelledByElement({shadowRoots: [shadowRootOfLabelElement]});
// returns the div inside the shadow root

@rniwa
Copy link
Author

rniwa commented Oct 22, 2020

I'm very excited about this one, thanks @rniwa for taking the time to address this. We raised that concern in #107 more than a year ago about programatic access to elements from another shadow (reflection of id and elements), as it breaks the encapsulation, making is impossible for us to use such feature.

Right, that was the concern Mozilla raised as well, so we decided to take a stab at it, and it seems like this API might work quite well.

The declarative aspect of this proposal aligns very well for us, and the parts precedent makes me think that this could work very well. I do not think we need a specific imperative API, developers can simply rely on the DOM apis for attributes, plus the traversal mechanism of a shadow to discover those elements is the shadows are open.

Right, what I like most is the parity with ::part. The fact same pattern is emerging here is a good sign that we've got the basic design of ::part right, and a further evidence that this approach may work well for accessibility use case as well.

I think the only annoyance here is that you'd have to forward IDs at each level of shadow boundary but that might be actually a benefit depending on how you look at it. It would make the relationship between different (shadow) trees more explicit.

@mfreed7
Copy link

mfreed7 commented Oct 22, 2020

I recall at some point we talked about having a mechanism to pass in a shadow root to some kind of accessor method, but I've forgotten the context.

You might be thinking of the getInnerHTML({closedShadowRoots: [...]}) API being defined in the declarative shadow dom spec, for which you did the TAG review. This suggestion is pretty similar.

@rniwa
Copy link
Author

rniwa commented Oct 22, 2020

I recall at some point we talked about having a mechanism to pass in a shadow root to some kind of accessor method, but I've forgotten the context.

You might be thinking of the getInnerHTML({closedShadowRoots: [...]}) API being defined in the declarative shadow dom spec, for which you did the TAG review. This suggestion is pretty similar.

This is a bit tangential but that API can't possibly just take closed shadow roots. It needs to take both modes of shadow roots since it would violate the encapsulation either way. The fact open mode allows a deliberate access isn't an excuse for adding new APIs that break encapsulation.

@mfreed7
Copy link

mfreed7 commented Oct 22, 2020

This is a bit tangential but that API can't possibly just take closed shadow roots. It needs to take both modes of shadow roots since it would violate the encapsulation either way. The fact open mode allows a deliberate access isn't an excuse for adding new APIs that break encapsulation.

As you said, this is definitely tangential. Let’s discuss on the declarative SD thread. But I don’t understand how this “breaks” the encapsulation of open shadow roots. They’re already accessible via JS, via element.shadowRoot.

@annevk
Copy link

annevk commented Oct 23, 2020

I think this is great. This matches what James and I discussed, but it's actually concrete rather than a conceptual model. 😊 Kudos.

And yeah, it seems that something like <label>.control could return the other element in the same tree (which would then delegate to its shadow tree "internally"). Not entirely sure what should happen if <label> was itself in a shadow tree and the element it points to was in another one though. Perhaps in that case it would be okay for the JavaScript reference to cross tree boundaries, as long as it only gets lighter.

@rniwa
Copy link
Author

rniwa commented Oct 27, 2020

@annevk : What do you feel about expanding this for all other IDs? For things like label.forand SVG use element.

@annevk
Copy link

annevk commented Oct 28, 2020

That seems reasonable, but we probably need an upfront enumeration of the affected places so we can update them all to use this new primitive. I rather not repeat the shadow tree whack-a-mole that's still ongoing.

@alice
Copy link
Member

alice commented Nov 2, 2020

Just to get a feel for what it might be like to use this kind of API, I had a go at writing out some more complex examples.

I took the liberty of assuming that like exportparts, we could use id as a shorthand for id:id, and that we could comma-separate multiple values.

Referring to sibling shadow roots, and to slotted elements in outer DOM:

<my-label id-maps="target-input:combobox-input">
  #shadowRoot
  | <label for="target-input"><slot></slot></label>
  #/shadowRoot
  Name
</my-label>
<custom-combobox id-maps="opt1, opt2, opt3, inner-input:combobox-input">
  #shadowRoot
  |  <input id="inner-input" aria-activedescendant="opt1"></input>
  |  <slot></slot>
  #/shadowRoot
  <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>
// assume code here to set up appropriate variables pointing to elements with equivalent IDs

innerInput.ariaActiveDescendantElement = opt2;
console.log(innerInput.getAttribute("aria-activedescendant"));  // logs "opt2", because the ID is mapped  

Referring up two levels of Shadow DOM:

<template id="component-template">...</template>
<my-app id-maps="component-template">
  #shadowRoot 
  | <my-section id-maps="component-template">
  | #shadowRoot
  | | <my-component template="component-template"></my-component>
  | </my-section>
</my-app>

Does that match what you envisioned, @rniwa, @annevk, @jcsteh, @mfreed ..?

@rniwa
Copy link
Author

rniwa commented Nov 3, 2020

We probably need a mechanism for a shadow tree for opt-in maybe. exportedid=foo or something like id=foo exportid. This is analogues to how part allows shadow trees to opt-in elements to be exported as a part.

@alice raised a concern / commentary that people seem to find that the ID mapping does bidirectional mapping was confusing for some people. We could consider making them two separate attributes like exportids or importids.

@alice
Copy link
Member

alice commented Nov 4, 2020

It would also be helpful to have a programmatic way to add/remove things from id-maps (or importids, etc) similar to element.style - doing all that string processing is going to be a big pain.

@chrisosaurus
Copy link

chrisosaurus commented Nov 4, 2020

I can see how the syntax is somewhat confusing. From looking at just the id-maps you can't tell which direction information is intended to flow.

<my-custom-element id-maps="inner : outer"></my-custom-element>

From context you might infer that the id-maps is exporting

<input aria-labelledby="outer"></input>
<my-custom-element id-maps="inner : outer"></my-custom-element>

But then we come across a new line, and have to instead infer that the id-maps is importing

<label id="outer">outer label</label>

You can imagine that this bidirectionality may lead to subtle and hard to find author errors

<my-custom-element id-maps="inner : outer">
# shadowRoot
| <label id="inner">inner label</label>
</my-custom-element>

<label id="outer">outer label</label>
<input aria-labelledby="outer"></input>

It can also lead to unclear code

<my-custom-element id-maps="foo : outer"></my-custom-element>
<my-other-custom-element id-maps="bar : outer"></my-other-custom-element>

<!-- where  does my label come from? -->
<input aria-labelledby="outer"></input>

@bkardell
Copy link
Collaborator

bkardell commented Nov 4, 2020

@alice raised a concern / commentary that people seem to find that the ID mapping does bidirectional mapping was confusing for some people. We could consider making them two separate attributes like exportids or importids.

Yes, I was one of those people who told @alice I found this very confusing and shared a few examples that I thought kind of made my head spin. I suggested that separate attributes importids and exportids would be better as you need explicit mappings for each of those things independently anyway. This is the example I wrote as how I thought it made more sense..

<!-- the host exposes the idref available to it to the rest of the tree as b -->
<x-div exportids="a:b">
  #shadow (closed)
  | <div id="a" exportid>
  |    inner A is made available to the host for mapping
  | </div>
  /shadow
  D
</x-div>

This lets you have a map similar <from>:<to> for importids

<input id="foo">
<!-- the host exposes the idref foo to shadow tree as bar -->
<x-div importids="foo:bar">
  #shadow (closed)
  | <label for="bar">Label thing outside</div>
  /shadow
</x-div>

This seems much clearer to me and a couple of people I checked with. The one thing about this (either way I guess) that is kind of weird is that the exportid thing is kind of a two-step that matches the way most import/export things we are familiar with would work... The inner tree exposes something as 'available' and the outer tree asks for that and maps it to something itself. However, with import there isn't a similar 'ask for the thing made available from the outer tree and expose it to me. This seems to imply that the outer tree can kind of just shove ids into my inner tree space which seems just very weird and unfortunate and prone to bugs. Short of some kind of way use something like <label for="imported:bar"> I'm not sure how to avoid that though.

Separately, the answer to how should things like .getElementById(mappedId) work seem to have no universally good answer. If the answer is they refer to the host element that exposed it this seems to kind of imply that an 'id' means something new/the same element effectively appears to have multiple ids.

@annevk
Copy link

annevk commented Nov 5, 2020

What we do for getElementById() should match the ID selector. Not matching the host element for inward connections (exportids in Brian's examples) seems reasonable to me, but I can see arguments either way. Outward connections (importids) make even less sense, especially from the perspective of the ID selector.

@rniwa
Copy link
Author

rniwa commented Nov 8, 2020

@annevk : Do you think someone from Mozilla can drive this feature & propose the refined version given Mozilla raised the original concern with regards to crossing shadow boundaries?

@alice
Copy link
Member

alice commented Nov 9, 2020

@rniwa I assume you're asking @annevk about this issue? whatwg/html#6063

@alice
Copy link
Member

alice commented Jan 18, 2021

These are the current open issues here, as far as I can tell:

(Element Reflection)

  1. Should IDL attributes with a type of Element be able to refer from inner Shadow DOM to outer?

    For example:

    <my-autocomplete id="shadow-host">
      # shadowRoot 
      | <input id="A">
      | <slot></slot>
      # /shadowRoot
      <!-- B is in outer tree, slotted into A's shadow DOM -->
      <my-option id="B">Option</my-option>  
    </my-autocomplete>
    A.ariaActiveDescendantElement = B; // outer tree
    console.log(A.ariaActiveDescendantElement);  // B

(ID forwarding)

  1. Should ID forwarding be exclusive to ARIA?

    • Probably not. This may be useful for other DOM features like for=, form=, and potentially associating a custom element with a <template> in the context of declarative Shadow DOM.
  2. Should ID forwarding be bi-directional (e.g. id-maps=inner-id:outer-id), or should we have separate ID export/import mechanisms (e.g. id-exports=inner-id:outer-id, id-imports=outer-id:inner-id)?

  3. Should elements inside Shadow DOM need to opt in to having their IDs exported?

    For example:

    <!-- the host exposes the idref available to it to the rest of the tree as b -->
    <x-div exportids="a:b">
      # shadowRoot
      | <div id="a" exportid>
      |    inner A is made available to the host for mapping
      | </div>
      # /shadowRoot
    </x-div>
  4. Should we have some kind of JS-based accessor to automatically set up ID mapping?

  5. Should we have a non-ID based mechanism for making elements inside of shadow DOM "visible" to elements outside of shadow DOM?

    For example:

    <label>State/Territory:
      <my-autocomplete>
        # shadowRoot
        | <input visible-to="for">
        # /shadowRoot
        <my-option>Australian Capital Territory</my-option>
        <my-option>New South Wales</my-option>
        <!-- etc -->
      </my-autocomplete>
    </label>
    class MyAutocomplete extends Element {
      connectedCallback() {
        const label = this.closest('label');
        if (!label || label.control !== null)
          return;
        
        // assuming `control` reflects the `for` content attr
        label.control = this._input;
      }
    }

Edit to add

  1. How does ID mapping interact with programmatic APIs?

    <label-element id-exports="inner_my_label:outer_radio_label">
        #shadowRoot
        |   <div id="inner_my_label">My radio label</div>
        |   <div id="inner_sibling">...</div>
        #/shadowRoot
    </label-element>
    <ul role="radiogroup" aria-labelledby="outer_radio_label">
        ...
    </ul>
    $('ul[role=radiogroup]').ariaLabelledByElement;  // returns <label-element>?
    document.getElementById('inner_my_label');    // same, or maybe we need a forwarding-aware version?

    a. How do we determine what each of these returns?
    b. What might a forwarding-aware version look like?

    document.getElementById('inner_my_label', {shadowRoot: label-element.shadowRoot});
  2. This comment from @web-padawan brings up something we've overlooked: component authors may wish to allow page authors to override the IDs of some elements in Shadow DOM. Should it be up to component authors to ensure there are no ID conflicts, or should this be something the component can opt in to?

@alice
Copy link
Member

alice commented Jan 20, 2021

Reworking the example in #169 (comment) using separate imports and exports:

<!-- using from:to order for both imports and exports -->
<!-- this means that import is outer:inner, *not* inner:outer order -->
<my-label id-import="combobox-input:target-input">
  #shadowRoot
  | <!-- should this opt-in to importing? -->
  | <label for="target-input"><slot></slot></label>
  #/shadowRoot
  Name
</my-label>
<!-- still using id as equivalent to id:id -->
<custom-combobox id-imports="opt1 opt2 opt3" id-exports="inner-input:combobox-input">
  #shadowRoot
  |  <!-- opt in to exporting ID -->
  |  <input id="inner-input" aria-activedescendant="opt1" export-id></input>
  |  <slot></slot>
  #/shadowRoot
  <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>

@alice
Copy link
Member

alice commented Jan 20, 2021

Discussed in today's meeting:

  • Referring from outer to inner via element reflection seems acceptable, and not completely inconsistent with Shadow DOM encapsulation principles (though @jcsteh feels like it somewhat doesn't fit completely either).
  • Is there a precedent for comma-separated lists in attribute values? Yes:
    • <meta name=keywords> takes a comma-separated list of keywords (which may have spaces in them, hence comma-separated) as its 'content` attribute
    • <input type=email> takes a comma-separated list of email addresses as its value attribute
    • <input type=file> takes a comma-separate list of MIME types as its accept attribute
    • <img>, <source> and <link> takes a comma-separated list of filename/size pairs as their srcset attribute (the pairs are space-separated)
  • None of those is using IDs though, so we might be better off making our attribute(s) space-separated.

@annevk
Copy link

annevk commented Jan 20, 2021

Yeah, it should be space-separated, just like the headers attribute.

@alice
Copy link
Member

alice commented Jan 20, 2021

Thanks @annevk. Did you have any thoughts on any of the other open questions?

@annevk
Copy link

annevk commented Jan 21, 2021

Not particularly, except that I would suggest to keep the initial version as simple as possible. So if something can be added later, opt for that.

@kbabbitt
Copy link

kbabbitt commented Jan 22, 2021

Thanks for those examples @alice - it's really helping me wrap my brain around this.

I think one reason I was struggling is that, when I look at the following, I don't see "import an id" but rather "export a <label>":

<!-- using from:to order for both imports and exports -->
<!-- this means that import is outer:inner, *not* inner:outer order -->
<my-label id-import="combobox-input:target-input">
  #shadowRoot
  | <!-- should this opt-in to importing? -->
  | <label for="target-input"><slot></slot></label>
  #/shadowRoot
  Name
</my-label>

@bkardell's earlier comment also rang true with me:

The inner tree exposes something as 'available' and the outer tree asks for that and maps it to something itself. However, with import there isn't a similar 'ask for the thing made available from the outer tree and expose it to me. This seems to imply that the outer tree can kind of just shove ids into my inner tree space which seems just very weird and unfortunate and prone to bugs.

With that in mind, what if we had the following:

  1. A given tree chooses elements, rather than identifier strings, to export across shadow boundaries.
  2. There is no "import." Instead, elements can be "exported" either inwards or outwards.
  3. Exporting an element across a boundary is separate from remapping an identifier string across a boundary.

To further iterate on the example:

<!-- using outer:inner order for mappings -->
<my-label remap-ids="combobox-input:target-input">
  #shadowRoot
  | <!-- opt in to exporting element -->
  | <label for="target-input" exported><slot></slot></label>
  #/shadowRoot
  Name
</my-label>
<!-- outer document can set active descendant via id map instead of reaching into inner document -->
<custom-combobox remap-ids="opt1:inner-activedescendant combobox-input:inner-input">
  #shadowRoot
  |  <!-- no need to export this element anymore -->
  |  <input id="inner-input" aria-activedescendant="inner-activedescendant"></input>
  |  <slot></slot>
  #/shadowRoot
  <custom-optionlist>
    <!-- the outer document exports these elements to the inner document. -->
    <x-option id="opt1" exported>Option 1</x-option>
    <x-option id="opt2" exported>Option 2</x-option>
    <x-option id="opt3" exported>Option 3</x-option>
 </custom-optionlist>
</custom-combobox>

Hmm. Having written that out, one thing that feels missing is control over where an element is exported to. If the whole markup above were contained within another level of shadow DOM, we would want to export opt1..opt3 in the "inwards" direction but probably not the "outwards" direction.

@JanMiksovsky
Copy link

Sorry for not noticing this issue earlier. I wanted to mention that the original proposal here by @rniwa following the September 2020 meeting is a little different than what I’d proposed at that meeting.

As captured, my original suggestion had tried to avoid using shadow element IDs outside the shadow where they appear. Instead, the idea was that an element could use element internals to programmatically delegate its participation in ARIA relationships to its own shadow elements. The outer element could be referenced by ID by elements in the same tree as usual, but the IDs of the interior shadow elements would never be exposed outside the element.

The example in that comment shows some made-up syntax for delegating responsibility if an element wants to act as an active-descendant. Above Ryosuke raises a more complex example of a shadow element in one component being used to label a shadow element inside another. That might look like:

<label-element id="label">
    #shadow-root
        <div id="my_label">My radio label</div>
</label-element>
<my-element aria-labelledby="label">
    #shadow-root
        <ul role="radiogroup">
            <li role="radio">Item #1</li>
            <li role="radio">Item #2</li>
            <li role="radio">Item #3</li>
        </ul>
</my-element>
/* In constructor for label-element */
const root = this.attachShadow(...);
const internals = this.attachInternals();
internals.labelSource = root.getElementById("my_label");

/* In constructor for my-element */
const root = this.attachShadow(...);
const internals = this.attachInternals();
internals.labelTarget = root.getElementById("radiogroup");

Essentially, any element used as the source of a label (like label-element above) can elect to specify where in its shadow the label should come from. That label source would be used with aria-labelledby (above). It would also be used if label-element had a for attribute pointing to my-element.

On the receiving side of the label, an element that’s being labeled (above, my-element) can indicate a target in the shadow which should receive the label. That label target would apply if my-element were labeled via for or aria-labelledby, or directly via an aria-label.

Some benefits:

  • Shadow IDs are never exposed in markup.
  • Because the wiring is done through code, IDs are not strictly required inside the shadow either. The component can use querySelector, etc., to obtain an element reference however it likes.
  • The component author can change their shadow structure without forcing page authors to update the IDs they reference.
  • No new markup syntax — page authors work with the custom elements using the same ARIA attributes they have today. The markup portion above looks familiar because it’s what you’d write now. The fact that both label-element and my-element are delegating their ARIA responsibilities is a private implementation detail.
  • This skirts some of the inner:outer vs outer:inner confusion referenced above; there’s no new map syntax period.
  • Could be aligned with AOM properties on elementInternals?

This isn’t a full proposal, just pointing out that maybe we can avoid relying on IDs as the main connectors.

@alice
Copy link
Member

alice commented Feb 7, 2021

@JanMiksovsky Could you show how that proposal might apply to the example I wrote out above?

And, I assume labelTarget and labelSource are shorthands; that would actually be something like ariaLabelledByTarget and ariaLabelledBySource, right?

Finally, it would be good to have a declarative option for use with declarative Shadow DOM. I wonder if @mfreed7 has any plans for a declarative version of ElementInternals.

@Westbrook
Copy link
Collaborator

If I have a combobox UI deep within a number of shadow root and I need to throw the listbox element of the pattern to the end of the body in order to escape CSS clipping/stacking, with this maps approach would a developer then need to walk up to the first shadow barrier under the body creating remap-IDs (or current proposal) on every boundary creating element in order for the references that would have even available across a single maps (or IDL as previously proposed) in order to conserve the aria association here?

@JanMiksovsky
Copy link

@alice I've posted a gist that develops the idea a bit further, and applies it to your combo box example. I've also tried to clean up the property names a bit based on my understanding of AOM, but this is still just a napkin sketch.

There's probably a lot to work here — would be happy to set up a real-time discussion with you and other interested parties to hash this out a bit more.

@keithamus
Copy link

WCCG had their spring F2F in which this was discussed. You can read the full notes of the discussion (WICG/webcomponents#978 (comment)) in which this was discussed, heading entitled "ARIA Mixin & Cross Root ARIA" - where this issue was specifically discussed.

In the meeting, present members of WCCG reached a consensus to discuss further in breakout sessions. I'd like to call out that WICG/webcomponents#1005 is the tracking issue for that breakout, in which this will likely be discussed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests