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

RFC: Element Handles for Cross-root ARIA #200

Closed
wants to merge 7 commits into from

Conversation

behowell
Copy link
Collaborator

@behowell behowell commented Jul 19, 2023

ℹ️ 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 or for, 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.

Copy link
Collaborator

@nolanlawson nolanlawson left a 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:

  1. Avoids the bottleneck effect
  2. Is not excessively verbose (there is some inherent verbosity due to the complexity of the problem)
  3. Works for for and potentially other IDref-likes (e.g. popovertarget)
  4. Doesn't distinguish between open and closed shadow roots (blocker from WebKit folks)
  5. Works declaratively and imperatively
  6. Can point both in and out of shadow roots
  7. 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

element-handles-explainer.md Outdated Show resolved Hide resolved
element-handles-explainer.md Outdated Show resolved Hide resolved
element-handles-explainer.md Outdated Show resolved Hide resolved
element-handles-explainer.md Show resolved Hide resolved
element-handles-explainer.md Outdated Show resolved Hide resolved
element-handles-explainer.md Outdated Show resolved Hide resolved

// The attributes also allow an ElementHandle to be set on them
combobox.ariaActiveDescendantElement = document.createElementHandle(document.getElementById('x-listbox-1'), 'opt2');
</script>
Copy link
Collaborator

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?

Copy link
Collaborator Author

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?

Copy link
Collaborator

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.

@EisenbergEffect
Copy link

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 x-label has a label in its Shadow DOM and x-input has an input in its Shadow DOM, could I have an observed attribute on x-label for for which took the "my-input" value, looked up the element in the parent scope, and then re-wrote the value to map to the internal handles via the JavaScript API?

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?

@alice
Copy link
Member

alice commented Aug 15, 2023

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 exportid also has the function of exporting the id if its value is blank - and I really like that.

The original handle proposal reminded me of how when I was chatting with @bkardell about his globalid idea, one of the things I liked most was that adding a globalid attribute functioned as a mandatory opt-in to being referred to from elements outside of the shadow root. I think the opt-in aspect is also a significant addition compared to the earlier exportids proposal, which required no such opt-in.

The additional behaviour of reusing the id attribute by default, while adding what amounts to an aliasing mechanism,
keeps it tied to existing IDREF mechanisms in a way that handle doesn't do as well.


So, that said, here's my bikeshed colour proposal for the renaming/slight redesign:

  • handle becomes exportid (same as your proposal)
  • exporthandles becomes forwardids (rather than reexportids; still works exactly as you designed)
  • importhandles becomes receiveids (rather than the data--like scheme; works like importhandles)
    • I initially had this as importids but @ZoeBijl gave me some good feedback that the misleading parallel with exportid was confusing.
    • The data--like scheme is clever and certainly reduces verbosity, but I feel like it's too much of a one-off and makes the whole thing feel a bit "syntax-y", especially with the @ signifier for the attribute values.
  • "host-id::handle(my-element)" becomes "host-id::shadow(my-element)"
    • I'm not thrilled about this, but I think it conveys what's happening adequately;
      perhaps someone else will come up with something better.
  • ":host::handle(my-element)" becomes ":host::id(my-element)"
    • My thinking here is that it's at least succinct, and tries to echo "receieveids",
      so you could think of it as "ask my host for this ID, which it has received".

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>

And in colour:
Screenshot of above code snippet, with colour highlighting to show how IDs are exported, forwarded, receieved, and finally used. For example, instances of the strings "my-activedescendant" and "active" are all highlighted red to show how "active" is set as an exportid alias on the listbox option with ID "opt1", and then re-aliased to "my-activedescendant" in the "receiveids" attribute on the <x-input>.


Then, one idea I had for another slight functionality change was to fold the getElementByHandle() functionality into getElementById(). Since exportid defaults to reusing the existing id value, I think it might make sense to use the existing function, with a couple of caveats.

Firstly, there is at least one difference in functionality between getElementByHandle() and getElementById(), which is:

In the event that the referenced handle was an exported handle, this returns the element that has the exporthandles attribute, and does not drill into the nested shadow tree.

(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:

When accessing a property that refers to an element via handle, the returned element will be retargeted in the same way that's done for event targets in shadow DOM. In practice, this is typically the host element (i.e. the element specified before ::handle()) [...]

So then, assuming we're folding the functionality into getElementById(), that would look more like (after rewriting the HTML to use forwardids):

<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

  • an element in the same scope which is someElement's aria-activedescendant-associated element,
    or
  • an element in the same scope which is the closest shadow-including ancestor to someElement's aria-activedescendant-associated element.

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 getElementById(), something like:

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

@alice
Copy link
Member

alice commented Aug 15, 2023

@EisenbergEffect

What I'm wondering is whether I could write JavaScript in my component to make it work like a built-in.

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.

@behowell
Copy link
Collaborator Author

Thank you for the detailed reply and input @alice! I'm liking the idea of exportid, but I'm worried that the blurred lines between IDs and exportIDs could make it harder to design the API in a way that is intuitive while preserving encapsulation of closed shadow roots. This is especially true around deciding exactly how the getElementById function works (discussed below).

Regarding your ideas for names:

  • I like the name forwardids 👍
  • I do agree about the confusing false-equivalence between exportids/importids. The name receiveids works, although it is a little awkward IMO.
    • How about useids (referenced as ":host::useid(exampleimport)" or ":host::id(exampleimport)")?
  • Or, if we kept the idea of an attribute family (like data-*), then the prefix could be useid-* or even just id-*. The @ syntax for referencing imported IDs was certainly not the only way to reference them. Brainstorming some other options to reference id-exampleimport="...":
    • ":host[id-exampleimport]"
    • ":host::id(exampleimport)"
  • I like that the syntax examplehost::shadow(examplechild) clearly indicates that it is going into the shadow tree. However, it's not as clear that it relates to the exportid attribute, since the word "shadow" doesn't show up elsewhere in this syntax. It's also potentially confusing to a user of a web component, since shadow DOM is more of an implementation detail of web components, rather than part of the API.
    • How about "examplehost::id(examplechild)"? That could be a good parallel to the ":host:id(exampleimport)" syntax.
    • In the case of handles, both the attribute handle="" and the syntax ::handle() had the same name. Honestly, this is a potential reason I might prefer to keep the attribute name as "handle", to reduce confusion with this syntax.

Regarding getElementById("examplehost::shadow(examplechild)"):

  • For open shadow roots, I agree it seems okay to return the child element.
  • For closed shadow roots, it can't return the child unless you have access to the shadow root, or that would break encapsulation.
    • It could return null by default, but there still needs to be a way to get the element if you have access to the shadow root.
    • Do we add a { shadowRoots: [...] } argument to getElementById? I suspect that modifying the API of getElementById would get some pushback, but I don't have anything to base that on.

I do still think we need some way to get an element by its exportid from a ShadowRoot. Just plain shadowRoot.getElementById() won't work, because exportid allows you to specify a different name from the element's ID. For example:

<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 getElementByExportId() function would come into play. We could theoretically make getElementById() also return elements by exportid, but then we almost certainly would need to make that be opt-in ({ exportIds: true }) to avoid a can of worms in changing the default behavior.


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.

@alice
Copy link
Member

alice commented Aug 24, 2023

Thanks for the response! Some comments/clarifications:

  • How about useids (referenced as ":host::useid(exampleimport)" or ":host::id(exampleimport)")?

I like that!

  • Or, if we kept the idea of an attribute family (...)

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 (id-something="xyz") from an attribute value (xyz=":host[id-something]" or whatever it may be).

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.

  • How about "examplehost::id(examplechild)"? That could be a good parallel to the ":host:id(exampleimport)" syntax.

Yeah, I like that too!


Regarding getElementById("examplehost::shadow(examplechild)"):

  • For open shadow roots, I agree it seems okay to return the child element.

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 ariaActiveDescendant), and then if it's an open shadow root, you can use the .shadowRoot property to (recursively if need be) get the child element:

<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 getElementById() so you don't need to "unwrap" the ID like that (and also, as you mention, to make pulling elements out of closed shadow roots more straightforward if you already have a reference to the closed shadow root):

document.getElementById("x-listbox-1::id(opt1)", { shadowRoots: [xListbox1.shadowRoot] });
  • I suspect that modifying the API of getElementById would get some pushback, but I don't have anything to base that on.

Yeah, I also suspect that might be a sticking point, but nothing ventured, nothing gained 😁

I do still think we need some way to get an element by its exportid from a ShadowRoot. Just plain shadowRoot.getElementById() won't work, because exportid allows you to specify a different name from the element's ID.

Hm, I don't see how that follows - I've been thinking of exportid as effectively an aliasing mechanism - so you can refer to the id attribute value or any of the exportid values as an element's "ID". So, like:

const input = xInput.shadowRoot.getElementById("internal-name");  // gets the <input>
const input = xInput.shadowRoot.getElementById("public-name");    // also gets the <input>

@behowell
Copy link
Collaborator Author

behowell commented Sep 5, 2023

Thanks again for the detailed reply @alice! I've been giving more thought to the exportid option, and I think it could be the best option.

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 exportid are:

  1. We'd be adding the ability for an element to have more than one ID, which has never been the case for ID before.
  2. If getElementById also searched the alias IDs from exportid like you mentioned in your comment, then we'd have an odd situation where getElementById works for the alias, but it doesn't work in querySelector or in CSS.

The main "nice-to-haves" that would be lost by not allowing renaming are:

  1. Not requiring the "public" ID to be the same as a potential "internal" ID. This seems like a relatively weak benefit -- just pick a good name to be the ID that works as the public ID, and use it internally as well.
  2. Allow applying multiple alias IDs to an element. E.g. Example 5 in this RFC applies the "active" item in a combobox dropdown, and then moves that around when the active item changes. That allows the dropdown to track which item is active internally, rather than the combobox needing to change its aria-activedescendant. This may be legitimately nice to have, but it's not one of the requirements for cross-root ARIA to work, and we've been doing fine without something like this before by changing the value of aria-activedescendant .

With that said, if exportid were only a boolean property, it simplifies the design a bit, and maintains the idea that an element can have only one ID.

I've rewritten this RFC entirely using the exportid property as a boolean (no renaming), and incorporated your feedback. Given that it's quite a bit different, I'm planning to make a separate PR with the exportid property, to replace this PR. I'll reply back once that is posted.

@behowell
Copy link
Collaborator Author

behowell commented Sep 5, 2023

I've created an RFC for the new ExportID proposal. It would be great to continue further discussion on that PR. Thanks!

@behowell
Copy link
Collaborator Author

Closing this RFC in favor of the ExportID rewrite: #204. Please continue the discussion on that PR.

@behowell behowell closed this Sep 11, 2023
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

Successfully merging this pull request may close these issues.

4 participants