-
Notifications
You must be signed in to change notification settings - Fork 59
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
RFC: Element Handles for Cross-root ARIA #200
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I really love this proposal. The biggest things it has going for it compared to other proposals, IMO, are:
- Avoids the bottleneck effect
- Is not excessively verbose (there is some inherent verbosity due to the complexity of the problem)
- Works for
for
and potentially other IDref-likes (e.g.popovertarget
) - Doesn't distinguish between open and closed shadow roots (blocker from WebKit folks)
- Works declaratively and imperatively
- Can point both in and out of shadow roots
- Has enough parallels with
::part
that we don't need to invent a lot of new concepts
I'd love to hear from other folks (especially other browser implementers) about what they think of this proposal.
FYI @leobalter @caridy
|
||
// The attributes also allow an ElementHandle to be set on them | ||
combobox.ariaActiveDescendantElement = document.createElementHandle(document.getElementById('x-listbox-1'), 'opt2'); | ||
</script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In your original proposal, you had this concept of an "element handle" proxy object. I kind of liked it, but I admit that there isn't a strict need for it, since you can pass around strings instead. (Which is pretty similar to what developers do today in e.g. the React ecosystem.)
It does greatly simplify things that you don't need an extra kinda-sorta-Element proxy object, but on the other hand, authors will probably end up writing a lot of string concatenation and string parsing code instead. I wonder if it would make sense to introduce some helper APIs to do this? Or would that be overkill?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current proposal is an evolution of the proxy object idea from an earlier draft. Rather than an opaque object, it is instead the host Element itself, plus the handle name.
The reasons I abandoned the opaque proxy object were:
- It's not backwards compatible with APIs that return Element objects today, like
ariaActiveDescendantElement
. Existing code wouldn't know to check for the new object. - I wanted to incorporate Alice's proposal to perform retargeting on the returned element, from Encapsulation-preserving IDL Element reference attributes.
- The retargeted element is (usually) just the host element, so what if those attributes returned an "enhanced" retargeted element: something that was the host element plus the handle to the element inside. You could use the handle to access the "real" target if desired (and the shadow root is open).
I don't think this proposal requires doing manual string parsing. The object is an Element (host), plus the (already-parsed) name of the handle.
Unless you're referring to an API similar to getElementById
, but would take a string like foo::handle(bar)
and return you the object representing foo plus the handle bar?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the late reply. I was gaming it out in my head, and it just seemed to me that I would probably end up doing string concatenation at some point to create strings like::this(one)
.
For example, the <x-listbox>
component may have a public JS API that returns the handle
of the currently-active option, which means that the <x-input>
component would have to construct the string x-listbox::handle(<name here>)
. Maybe this is just a bad design, and the listbox component should use a consistent handle
for whichever one happens to be active (as in your example), but there may be other cases where the outer component has to query the state of the inner component to figure out which kinds of handles are available.
Either way, it's not a big deal – it seems pretty straightforward to do the string concatenation.
This idea is extremely powerful and flexible, which is great. I don't mind the syntax complexity as an internal implementation detail of my reusable components. However, it's a sizeable burden on a consumer of the component and makes interfacing with Web Components different than using built-ins. That's a huge negative in my book. What I'm wondering is whether I could write JavaScript in my component to make it work like a built-in. For example, if I want the consumer to write this HTML: <x-label for="my-input">...<x-label>
<x-input id="my-input"></x-input> Assuming that If that's possible, then I think this is a pretty good solution. It would enable a fully declarative mechanism great for DSD, but also an imperative mechanism that could improve ergonomics in the most common scenarios, without blocking edge cases. Thoughts? |
Apologies for the long comment! I really do like the direction this is headed, and now that I've had some time to think about it I thought of some tweaks that I think might make it more streamlined while keeping it headed in much the same direction. I spent a bit of time noodling on the renaming ideas you laid out in the appendix. It's not quite a strict rename, since The original The additional behaviour of reusing the So, that said, here's my bikeshed colour proposal for the renaming/slight redesign:
So, the combobox kitchen sink example would look like: <label for="x-combobox-1::shadow(input-exported-id)">Example combobox</label>
<x-combobox id="x-combobox-1">
#shadowRoot
| <x-input
| forwardids="input-exported-id"
| receiveids="my-activedescendant: x-listbox-1::shadow(active),
| my-listbox: x-listbox-1::shadow(listbox-exported-id)">
| #shadowRoot
| | <input
| | role="combobox"
| | id="input-internal-id"
| | exportid="input-exported-id"
| | aria-controls=":host::id(my-listbox)"
| | aria-activedescendant=":host::id(my-activedescendant)"
| | aria-expanded="true"
| | />
| </x-input>
| <button aria-label="Open" aria-expanded="true">▼</button>
|
| <x-listbox id="x-listbox-1">
| #shadowRoot
| | <div role="listbox" id="listbox-exported-id" exportid>
| | <div role="option" id="opt1" exportid="opt1 active">Option 1</div>
| | <div role="option" id="opt2" exportid>Option 2</div>
| | <div role="option" id="opt3" exportid>Option 3</div>
| | </div>
| </x-listbox>
</x-combobox> Then, one idea I had for another slight functionality change was to fold the Firstly, there is at least one difference in functionality between
(Emphasis added.) This doesn't have a worked example in the explainer that I could see, but I assume that, in the Referring through multiple layers of shadow trees example, that would look something like: <label for="x-combobox::handle(the-input)">Example Label</label>
<x-combobox id="x-combobox">
#shadowRoot
| <x-input handle="x-input" exporthandles="the-input">
| #shadowRoot
| | <input handle="the-input" type="text" />
| </x-input>
</x-combobox> const xCombobox = document.getElementById("x-combobox");
const theInput = xCombobox.shadowroot.getElementByHandle("the-input");
// ^ returns the <x-input>, since that is where the "the-input" handle is exported I'm not sure what we want that functionality for exactly (perhaps you had a use case in mind?); I think it might be clearer to have a more general rule which aligns with those outlined in Properties that reflect IDREF attributes as Element objects:
So then, assuming we're folding the functionality into <label for="x-combobox::the-input">Example Label</label>
<x-combobox id="x-combobox">
#shadowRoot
| <x-input id="x-input" forwardids="the-input">
| #shadowRoot
| | <input id="the-input" exportid type="text" />
| </x-input>
</x-combobox> xCombobox.shadowRoot.getElementByid("the-input");
// returns null; ignore the `forwardid` which is really for
// the light DOM around the `<x-combobox>` rather than inside
// its shadow root
xCombobox.shadowRoot.getElementById("x-input::shadow(the-input)");
// returns the <x-input> That way, there would be an equivalence between: document.getElementById(someElement.getAttribute("aria-activedescendant")); and someElement.ariaActiveDescendantElement; i.e. in both cases it would return either
Secondly, it might be confusing to potentially not get back an element which has the identical ID to the value passed to the function. If that's an issue, perhaps we could consider adding an option to xCombobox.shadowRoot.getElementById("x-input::shadow(the-input)",
{ exportedIds: true });
// returns the <x-input>
xCombobox.shadowRoot.getElementById("x-input::shadow(the-input)");
// returns null; default behaviour |
We've been working on a proposal to address the common case of wanting a custom element to "wrap" a built-in element: #199 This proposal is intended as a much more powerful and flexible option upon which less verbose and flexible solutions could be prototyped. |
Thank you for the detailed reply and input @alice! I'm liking the idea of Regarding your ideas for names:
Regarding
I do still think we need some way to get an element by its <x-input id="x-input">
# shadowRoot (open)
| <input id="internal-name" exportid="public-name" />
</x-input>
<script>
const xInput = document.getElementById('x-input');
const input = xInput.shadowRoot.getElementById("public-name"); // null, since that isn't the ID
</script> That's where the Let me think more about how to incorporate these ideas into the proposal. I'll try to push an update next week with some changes based on this feedback. |
Thanks for the response! Some comments/clarifications:
I like that!
I think the other thing that doesn't quite sit right with me about the attribute family idea is that it's referring to something that's specified as an attribute name ( Obviously at the end of the day it's all just strings in practice, but I keep finding it really confusing to remember how it works (to the point where even while writing this comment I had to check several times, after initially getting it wrong), and I think it's this "crossing the streams" issue that's particularly tripping me up. I think this is also unprecedented in HTML.
Yeah, I like that too!
Hm, I didn't intend that you'd ever get back the child element - my intent was that you'd get back the host (just like you do for getting the IDL attribute for something like <input id="combobox-1" role="combobox" aria-activedescendant="x-listbox-1::id(opt1)" />
<x-listbox id="x-listbox-1">
#shadowRoot
| <div role="listbox" id="the-listbox" exportid>
| <div role="option" id="opt1" exportid>Option 1</div>
| </div>
</x-listbox> combobox1.ariaActiveDescendantElement;
// returns the <x-listbox>
document.getElementById(combobox1.getAttribute("aria-activedescendant"));
// *also* returns the <x-listbox>
document.getElementById("x-listbox1::id(opt1)").shadowRoot.getElementById("opt1");
// returns the <div role="option"> We might also want a way to pass in the shadow root(s) to document.getElementById("x-listbox-1::id(opt1)", { shadowRoots: [xListbox1.shadowRoot] });
Yeah, I also suspect that might be a sticking point, but nothing ventured, nothing gained 😁
Hm, I don't see how that follows - I've been thinking of const input = xInput.shadowRoot.getElementById("internal-name"); // gets the <input>
const input = xInput.shadowRoot.getElementById("public-name"); // also gets the <input> |
Thanks again for the detailed reply @alice! I've been giving more thought to the However, one of my biggest concerns is the idea of having the exported ID be potentially different from the element's ID itself, which seems to create more problems than it solves. The main issues of renaming via
The main "nice-to-haves" that would be lost by not allowing renaming are:
With that said, if I've rewritten this RFC entirely using the |
I've created an RFC for the new ExportID proposal. It would be great to continue further discussion on that PR. Thanks! |
Closing this RFC in favor of the ExportID rewrite: #204. Please continue the discussion on that PR. |
ℹ️ Please see RFC: Exporting IDs from shadow roots for cross-root ARIA for an updated proposal.
I've written a proposal for solving the cross-root ARIA problem with a new feature called handles.
Element handles are a way to refer to an element inside a shadow tree from an ID reference attribute like
aria-labelledby
orfor
, while preserving shadow DOM encapsulation. Handles can be summed up as "like shadow parts, but for ID references." Much of the API is designed to be parallel to the shadow parts API and follows similar syntax.The full proposal is in this PR, and you can see a formatted version here:
📜 Element Handles for Cross-root ARIA
I'd really appreciate any feedback and comments! You can leave comments directly on the .md file in this PR.
Thank you to @alice. @nolanlawson, and @Westbrook for taking an earlier look at this proposal and giving feedback.