Open rniwa opened 4 years ago
As discussed, this seems more general than ARIA, so the name aria-maps
may not be general enough.
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 (https://github.com/whatwg/html/issues/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.
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
.
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?
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.
Thanks for the initiative! Let me share a real world use case that we have in our components library.
vaadin-text-field
component with a <label>
in Shadow DOM, and it has id
attribute. label
property on the component to provide a text content for <label>
:<vaadin-text-field label="Login"></vaadin-text-field>
<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:
<label>
internally only in case if label
property is set on the component;id
attribute on the internal <label>
when the aria-maps
attribute is set;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?
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?
I'm very excited about this one, thanks @rniwa for taking the time to address this. We raised that concern in https://github.com/WICG/aom/issues/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.
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
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.
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.
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.
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.
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.
@annevk : What do you feel about expanding this for all other IDs? For things like label.for
and SVG use element.
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.
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 ..?
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
.
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.
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>
@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.
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.
@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?
@rniwa I assume you're asking @annevk about this issue? https://github.com/whatwg/html/issues/6063
These are the current open issues here, as far as I can tell:
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
for=
, form=
, and potentially associating a custom element with a <template>
in the context of declarative Shadow DOM.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
)?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>
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
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});
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?
Reworking the example in https://github.com/WICG/aom/issues/169#issuecomment-720241804 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>
Discussed in today's meeting:
<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)Yeah, it should be space-separated, just like the headers
attribute.
Thanks @annevk. Did you have any thoughts on any of the other open questions?
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.
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:
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.
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:
This isn’t a full proposal, just pointing out that maybe we can avoid relying on IDs as the main connectors.
@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
.
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?
@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.
WCCG had their spring F2F in which this was discussed. You can read the full notes of the discussion (https://github.com/WICG/webcomponents/issues/978#issuecomment-1516897276) 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 https://github.com/WICG/webcomponents/issues/1005 is the tracking issue for that breakout, in which this will likely be discussed.
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 ofmy-element
is exported as list in the outer tree, and ul is labeled by the div in the outer tree.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.
Combining these two things together, we can export a label from one shadow tree and use it in another shadow tree: