[a11y]: permit aria-hidden=true on focusable shadow elements in FACE
bennypowers opened this issue · comments
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"
- does the heavy ui lifting, sparing us from implementing everything with
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?
As discussed in #1005
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.
@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.
Thanks for asking @EisenbergEffect. I'm hesitant to offer anything in this thread as "good advice" for a few reasons:
- this whole idea is intended to be a temporary stopgap until stronger solutions are adopted, be they semantic delegate or a more general solution
- 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.
@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.
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 witharia-hidden=true
are hidden from the accessibility tree, undoing the fix which was added in order to prevent a poor experience from focusing onaria-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.
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, likecheckbox
,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 setformAssociated
to use:disabled
state and disable inner elements (eg: cards asfigure
) 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 iscombobox
, then you can't do it. But if it'srole=none
, then you can present thecombobox
AX Node and thebutton
AXNode next to each other as siblings. I can imagine other interactive icons likeClear input
orShow Password
would have similar container-like constructs.
My current, evolved interpretation of FACE is they:
- Are containers that can read
:disabled
state - Should have an ARIA role that match their container type.
- Can unifyingly set a single form value representing the value within the container
- *Can have labels passed to it.
- *Can have an AXLabel
- *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.