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

[a11y]: permit aria-hidden=true on focusable shadow elements in FACE #1014

Open
bennypowers opened this issue May 11, 2023 · 7 comments
Open

Comments

@bennypowers
Copy link

bennypowers commented May 11, 2023

consider:

class FACEInput extends HTMLElement {
  static formAssociated = true;

  static template = document.createElement('template');
  static {
    this.template.innerHTML = /*html*/`
      <input aria-hidden="true">
    `;
  }

  #internals = this.attachInternals();

  constructor() {
    super();
    this.#internals.role = 'textfield';
    this.attachShadow({ mode: 'open', delegatesFocus: true })
      .append(this.constructor.template.content.cloneNode(true));
    // handwave: imagine we add a "Reactive controller" which handles syncing aria props
    //           between the shadow root input and the host element.
    hookUpListenersAndAriaMixinProps({
      host: this,
      target: this.shadowRoot.querySelector('input'),
      internals: this.#internals
    });
  }
}

customElements.define('fancy-input', FACEInput);

Thus, we have

  • a FACE which acts exclusively as an input
  • a shadow input which
    • does the heavy ui lifting, sparing us from implementing everything with contenteditable etc
    • has aria-hidden="true"

In this case, the author's intent is for the host element (fancy-input) to act transparently as an input. BUT, the accessibility tree will report two nested textfields - one for the host (internals.role) and a nested one for the shadow input, even though it is marked by the author as aria-hidden.

As well, the 'hidden' input will trigger a failure for automated accessibility audits.

We should consider relaxing the rule which disallows aria-hidden=true and role=presentation for elements which are within a FACE' shadow root. This would allow FACE authors to remove focusable elements from the ax tree, imperatively delegating ARIA stuff to the host.

cc @annevk and @rniwa who had some pointed critique with regard to focus and keyboard events. If there was a way to overcome those critiques, could this be a viable (though less capable) alternative to @alice' semantic delegate proposal?

@bennypowers
Copy link
Author

As discussed in #1005

@bennypowers
Copy link
Author

bennypowers commented Jun 7, 2023

I'm grateful to @alice for her time over element chat while she participates in the Igalia WebEnginesHackfest. She graciously donated her time and (considerable) expertise to try and understand my intent with this issue and work through possible solutions.

In summary:

  • we're talking about a FACE that delegatesFocus: true, and wraps a single <input>. In various fora, the shorthand <fancy-input> represents this case
  • <fancy-input> is not very well supported by current specs
  • simply permitting aria-hidden=true on the shadow input, as this issue proposes, would not solve the problem as:
    • we want the AT focus to be on the shadow host
    • but the keyboard focus to be on the shadow input

The proposal would therefore be amended:

IF
  a shadow host delegatesfocus
  AND (
    is a FACE
    OR has internals attached
  )
THEN
  allow removing focusable shadow children from the AT
  have AT focus land on the host when keyboard focus is on the input

Alice recommended a workaround where

  • the host does not set a role
  • the custom element moves most aria attrs (those not blocked by cross-root aria) from the host to the shadow

@alice reports that rough experimentation with implementing the original proposal here (permitting aria-hidden) has better-than-expected results. the screen reader does report that a text field is focused, perhaps because the <input aria-hidden="true"> is within the host with role=textbox, so the host is the nearest ancestor with a role

for completeness, this proposal (originally, and with this comment's amendation) assumes the component developer has to hook up all the plumbing via element internals, but the developer advantage of not having to reimplement <input> with contenteditable remains.

@EisenbergEffect
Copy link

@bennypowers Just to be clear, if I were to implement a custom input, the guidance would be to:

  • hook up element internals
  • add aria-hidden=true to the shadow input
  • make the host delegate focus but do not put a role on the host
  • move any aria- from the host to the input.

Is that correct or did I mix together two different approaches? I'm building some learning contents around this so I want to make sure I've got the best approach in my demos.

@bennypowers
Copy link
Author

bennypowers commented Jun 7, 2023

Thanks for asking @EisenbergEffect. I'm hesitant to offer anything in this thread as "good advice" for a few reasons:

  1. this whole idea is intended to be a temporary stopgap until stronger solutions are adopted, be they semantic delegate or a more general solution
  2. My own understanding, and I would hazard to suggest that of the implementers as well, is rapidly advancing

That being said, as of this writing, your bullet points combine two separate and possibly incompatible solutions. This is how I understand things:

Solution for right now

  • don't set a role on the host
  • delegate focus
  • don't set aria-hidden
  • transfer attrs from lightdom

Possible future solution according to this proposal

  • set a role on the host
  • delegate focus
  • do set aria-hidden
  • possibly transfer some attrs, but probably not?

What I'm really hoping for is to gather multiple developers together with some implementers to develop advice for these components. It would be tremendous to get yourself, @nolanlawson, and @asyncLiz together with maybe @alice, @annevk and @rniwa, but perhaps that would be too onerous to arrange.

@EisenbergEffect
Copy link

@bennypowers Thanks for the clarification! I'm happy to get together. I don't think I can add much value to the conversation itself. I do want to make sure things are properly documented and propagate the recommended approaches to the community so we can have higher quality components in the wild though.

Let's keep updating this thread as things progress. I'll use this as a resource for whatever I'm teaching so I can both show people the best current approach as well as inform them of future directions.

@alice
Copy link
Member

alice commented Jun 7, 2023

Thanks for the summary, @bennypowers.

Just to add some nuance, the experiment I ran was:

  • In a local build of Chromium, I commented out the check for an element with aria-hidden=true being focusable in order to include it in the accessibility, so that all elements with aria-hidden=true are hidden from the accessibility tree, undoing the fix which was added in order to prevent a poor experience from focusing on aria-hidden elements.
  • I then experimented with https://codepen.io/sundress/pen/dyQyqjd?editors=1011 (forked from an earlier demo by @bennypowers) in my local build, using VoiceOver.
  • I could verify that the <input> was hidden from the accessibility tree, and that, surprisingly, when keyboard focus was placed on the <input>, there was an announcement that VoiceOver was on an edit field.
  • However, that's about where the success of the experiment ended: I couldn't get any label association to work, and input into the text field wasn't announced the way it is with the plain <input>.

I'm less than optimistic about this as a viable pathway even with this mild success; I think there will be a lot of details to figure out, and I would rather we spend that energy on one of the less hacky, longer-term, more general options.

@clshortfuse
Copy link

clshortfuse commented Jun 30, 2023

I mulled this over for a couple of hours.

  • I would stay with exposed inner <input>. The "correct" solution is to build your own element and replace native controls, The sane solution is to just use the native control elements (<input>, <textarea>, <select>). I decided to see how much would be worth to rewrite all my components the "correct" way. Simple elements seem okay, like checkbox, role, switch, but for the most part, you're better off staying with the native input. Things like <input type=range> gets some things you can't possibly polyfill, like tracking pointer position even though the pointer has left the bounding box. On a related note, a wrapped <a> element is AFAIK, the only way to properly show some native characteristics like the tooltip and the browser context menu showing "Open link in new window". That happens because you're tapping into native controls, not emulating them.

  • I would not risk marshalling focus state. I would not really mess with trying to manage focus between two elements with a non-exposed internal <input> element. I agree that it could get unwieldly. I remember both Firefox and Chromium bugs dealing with FACE related to focus. They are now fixed, but I can imagine hidden input focus could unearth some bugs on some browser / screen-reader combinations.

  • CEs are better as containers rather than directly interactive elements. This is more of an evolution I've noticed having worked on this for some time now. Perhaps coincidence, but all the CEs that do use a role on the host are containers: toolbar, figure, navigation, tabpanel, tooltip. All my FACEs that don't use a native control (eg: <input>) are essentially containers as well. They set formAssociated to use :disabled state and disable inner elements (eg: cards as figure) or because they expose one singular form value: (eg: listbox).

  • CEs as containers allows more complexity. Given the <fancy-input> example, if it's just a host-level interactive element, you will run into some issues trying to tap into more complex paradigms. For example, if you want to make it an combobox, with a drop down, you should include a clickable button the AXTree. That clickable button should not be inside your combobox. It should be adjacent to it. If your host-level role is combobox, then you can't do it. But if it's role=none, then you can present the combobox AX Node and the button AXNode next to each other as siblings. I can imagine other interactive icons like Clear input or Show Password would have similar container-like constructs.


My current, evolved interpretation of FACE is they:

  1. Are containers that can read :disabled state
  2. Should have an ARIA role that match their container type.
  3. Can unifyingly set a single form value representing the value within the container
  4. *Can have labels passed to it.
  5. *Can have an AXLabel
  6. *Can have an AXDescription

The starred points are the points of issue. Yes, a label and description can be passed, but we have no ability to pass that value down to whatever element of our choosing (if any) in the Shadow Root. I need to stress that there is a difference between the textContent of a label from .labels and the AXLabel. We cannot just take the textContent because a label can be like this:

<label for=foo>
  <i class=font-icon aria-hidden=true>person</i>Name
</label>
<fancy-input name=foo></fancy-input>

That means we need more than just text, we need the actually AXLabel to be passed down. Using .labels is not enough because textContent isn't safe. I've experimented with observing aria-labelledby and aria-describedby, finding the nodes, and putting a mutation observer, cloning in the shadowRoot and then referencing them, but it's a hacky solution and considering those nodes may not exist at the time the attribute is created, it's unreliable.


I took a look at https://github.com/alice/aom/blob/gh-pages/semantic-delegate.md and it seems it could satisfy my current issues with using external labels for some FACEs. My other concern is a singular element to receive all the ARIA attributes. I haven't had the use case for it yet off the top of head, but I wouldn't like to be restricted between using aria-label and aria-describedby to the same shadowed element. If I have a container-like FACE, aria-label can reference one element, but aria-controls may refer to another. As the author, I would like to choose where I use them. Of course, that doesn't mean extra attributes or ElementInternals properties can't be added later.

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

4 participants