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

linking elements cross boundaries is only possible imperatively and leads to anti-patterns #107

Open
caridy opened this issue Mar 2, 2018 · 12 comments

Comments

@caridy
Copy link

caridy commented Mar 2, 2018

Disclosure: This issue is just to brainstorm on the proposed linking mechanism and maybe open other avenues.

A very common use-case is to have custom elements that should be described by sibling elements,

<template>
   <span id="global-one">description for x-foo element</span>
   <fancy-input aria-describedby="global-one">
   </fancy-input>
</template>

Assuming that fancy-input element contains some internal focusable elements, and itself is relying on delegate focus to facilitate the user interaction, e.g.:

<template>
   <span id="global-one">description for x-foo element</span>
   <fancy-input aria-describedby="global-one">
        #shadow-dom (mode=closed, delegateFocus=true)
           <label id="local-one">...</label>
           <input aria-describedby="local-one" />
   </fancy-input>
</template>

It is still impossible to connect those elements from outside (#global-one) and from within (input) without opening some sort of side-channel on fancy-input element. I see two options to achieve this:

  1. expose a method on <fancy-input> custom element that when called with one argument (an element), it sets that element into the internal input's ariaDescribedByElements collection. (with the corresponding guards and deduping).
  2. expose a method on <fancy-input> custom element that when invoked without arguments return a reference to the internal input element.

Either way, it is going to be bad, error prompt or leaky, this is one of those situations when you have to take a poison.

Another option is to observe the aria attributes on the host, walk from the host all the way to the nearest root, and query for the ID from there to try to do some auto-wiring via imperative APIs. This should work fine, but the problem is that since you're in control of the situation as the author of the component, you will have to observe mutations to rewire when needed. This makes the situation very error prompt, while the option 1 and 2 outsource that responsibility to the consumer of the custom element.

Proposal

When discussing this with the team, we were wondering whether or not the delegate focus on the shadow root could be sufficient indication for some sort of compounding mechanism for some of these aria-* attributes that reference IDs. In the example above, you can see that from the consumer perspective (the owner of the template), you can do the regular connecting between the two elements via IDs, they are in the same shadow after all.

But from the component's author perspective, just signaling that the root should receive the focus (via delegateFocus configuration), could be used to build the right tree under the hood that connects the input with both elements (#global-one and #local-one) without the user having to do so manually.

Pros:

  • declarative works again
  • very intuitive (you just don't need to know how the fancy-input works)

Cons:

  • how to opt-out from this behavior? is it possible to opt-out?
@hober
Copy link

hober commented Mar 5, 2018

cc @alice, @cookiecrook

@alice
Copy link
Member

alice commented Mar 5, 2018

I don't quite follow the proposal - could you possibly add some "strawman" code which illustrates what you mean?

@caridy
Copy link
Author

caridy commented Mar 6, 2018

@alice this proposal is focus on one premise: replacing <input> with <fancy-input> should be as easy as changing the tag name in your markup (if the both share the same API).

To provide an actual example:

    class FancyInput extends HTMLElement {
        constructor() {
            super();
            var shadow = this.attachShadow({
                mode: 'open',
                delegatesFocus: true,
            });

            shadow.innerHTML = `
                <label id="fancy-label">internal label for input element</label>
                <input aria-describedby="fancy-label" />
            `;
        }
    }
    customElements.define('fancy-input', FancyInput);

    class MyComponent extends HTMLElement {
        constructor() {
            super();
            var shadow = this.attachShadow({
                mode: 'open',
            });

            shadow.innerHTML = `
                <label id="my-label">my custom label for fancy-label element</label>
                <fancy-input aria-describedby="my-label" />
            `;
        }
    }
    customElements.define('my-component', MyComponent);

If you declare those two components, and you insert <my-component></my-component> in the page. What happen when you focus on fancy-input? It will delegate the focus, but the aria-describedby will not be compounded (SR will announce only the internal label "internal label for input element").

The author of MyComponent will be guessing why is the label specified on its markup doesn't get announced? Although, changing <fancy-input> back to <input> will work. Our suggestion here is to consider the ability to define compounding rules for aria attributes if the shadow root happens to have delegatesFocus: true,,to match the behavior of the focus on those elements, which is already compounded.

It is important to notice that this is probably going to be one of the most common use-case. We can definitely provide some strawman, but first we will like to know if this was discussed in the past.

@SiTaggart
Copy link

SiTaggart commented Mar 6, 2018

So you're suggesting that using delegateFocus not only delegates away the focus to the focusable element inside the Shadow Root, but also delegates some attributes from the Custom Element to the focusable element inside, in an additive way?

Example:

class FancyInput extends HTMLElement {
  constructor() {
    super();
    var shadow = this.attachShadow({
      mode: 'open',
      delegatesFocus: true,
    });

    shadow.innerHTML = `
      <label for="fancy-input">internal label for input element</label>
      <input id="fancy-input" aria-describedby="fancy-input-error" />
      <span id="fancy-input-error">internal error message for the input element</span>
    `;
  }
}
customElements.define('fancy-input', FancyInput);

class MyComponent extends HTMLElement {
  constructor() {
    super();
    var shadow = this.attachShadow({
      mode: 'open',
    });

    shadow.innerHTML = `
      <fancy-input aria-describedby="my-additional-error" />
      <span id="my-additional-error">my custom error for fancy-input element</label>
    `;
  }
}
customElements.define('my-component', MyComponent);

<my-component></my-component> would render in the DOM as

<my-component>
  #shadow-root
  |  <fancy-input>
  |    #shadow-root
  |    |  <label for="fancy-input">internal label for input element</label>
  |    |  <input id="fancy-input" aria-describedby="fancy-input-error my-additional-error" />
  |    |  <span id="fancy-input-error">internal error message for the input element</span>
  |  </fancy-input>
  |  <span id="my-additional-error">my custom error for fancy-input element</label>
</my-component>

Where my-additional-error is added to the aria-describedby of the fancy-input input element?

Feels super weird that delegateFocus would be that vehicle. It also wouldn't account for things that potentially need referencing across shadow roots that aren't focusable. Consider Modal markup for example, I'd see if a fairly common that a modal component would be created as generic but the guts of the modal is in the modals shadow-root(s), but I want to label and describe the modal element by it's content.

<my-custom-modal role="dialog" aria-modal="true" aria-labelledby="id_of_visible_header" aria-describedby="id_of_modal_body">
  <my-custom-modal-header>
    #shadow-root
    |  <h2 id="id_of_visible_header">
    |    Modal header
    |  </h2>
  </my-custom-modal-header>
  <my-custom-modal-body>
    #shadow-root
    |  <div id="id_of_modal_body">
    |    Random content provided by component consumer
    |  </div>
  </my-custom-modal-body>
</my-custom-modal>

@cookiecrook
Copy link
Collaborator

It's not clear to me how this would work with possible inward-pointing references (e.g. aria-activedescendant) in addition to the outward-pointing references like aria-labelledby.

@alice
Copy link
Member

alice commented Mar 6, 2018

delegatesFocus was not designed exclusively for this type of "decorating" use case - it also covers cases where a custom element has multiple internal focusable elements. I don't think it's a good fit here.

This pattern of decorating built-in elements with custom elements has a number of issues like this one. I'm not sure what would be the best API to allow the behaviour you describe, but I agree it's desirable.

@caridy
Copy link
Author

caridy commented Mar 6, 2018

@SiTaggart

Feels super weird that delegateFocus would be that vehicle.

That's ok, maybe we can decouple this from focus, and just have a new configuration for shadow root that signals the auto-wiring of the accessibility descriptors. I should have started with it.

@cookiecrook

It's not clear to me how this would work with possible inward-pointing references (e.g. aria-activedescendant) in addition to the outward-pointing references like aria-labelledby.

that can works exactly as described above, where the internal element receiving the focus is auto-wired as the active descendant, without having to expose this to user-land. the entire proposal is to attack the most common use-cases so consumers of custom elements can thread them just like regular elements. I'm still missing the part where <label>something <fancy-input></fancy-input></label> works.

@alice

it also covers cases where a custom element has multiple internal focusable elements

we have discussed this particular case, e.g. <input-location> which contains latitude/longitude inputs, and we are not very clear on how that will work. That's why I was asking how to opt-out of this? It seems that if we use a different configuration, we could potentially opt-out by not providing that configuration in the shadow creation.

Again, the part that I want to stress here is that it is Not Okey to ask consumers of custom elements to do something different from what they normally do when using an input. It is also Not Okey to ask authors of custom elements to do very complicated gymnastics to do the right thing about accessibility, because we know where that leads.

Obviously we already made mistakes like not supporting labels in the same way they are supported for regular inputs, but we should be able to fix those and provide default behaviors that can cover a lot of grounds for the most common use-cases.

@cookiecrook
Copy link
Collaborator

that can works exactly as described above, where the internal element receiving the focus is auto-wired as the active descendant

There are a number of IDREFS-valued ARIA attributes that have no direct relationship to the currently focused element. For example, aria-controls and aria-owns.

@alice
Copy link
Member

alice commented Mar 7, 2018

The issue with the "decorator" pattern is fundamentally that you end up with two (or more) nested elements trying to act like a single element - you want the decorated element to act like the single element for some purposes, and the decorator for other purposes.

@robdodson
Copy link
Contributor

robdodson commented Mar 7, 2018 via email

@tomalec
Copy link

tomalec commented Mar 26, 2018

...and here whatwg/html#3219 - for imperative attaching of labelable element to the label element.

@caridy
Copy link
Author

caridy commented Oct 22, 2020

This is superseded by the proposal in #169, which is a lot more coherent.

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

7 participants