aom
aom copied to clipboard
Shadow inputs in Form-Associated Custom Elements should be allowed to be hidden from AOM
In this demo, a form-associated custom element provides a range slider. The FACE' shadow root contains a private input. The developer intends for the shadow input to be transparent to assistive technology, since the FACE exposes it's state to AT via ElementInternals
.
We expect AT to report a single interactive control here, but instead find that the Chrome dev tools accessibility panel lists two nested controls. AXE and Lighthouse audits also flag the shadow input as either being unlabeled, or having an illegal aria-hidden attr.
The developer's intent here is to use the native input functionality without reimplementing everything, while making the custom element accessible via ElementInternals
. Given this bug, the developer will either have to reimplement the element without using <input>
in shadow root (contenteditable
, <canvas>
, animated SVG, etc), or will have to apply one of several workarounds which are emerging, like moving aria-attributes from light to shadow DOM. All of which seem to run counter to the "intent" of the ElementInternals
APIs.
Please see the reproduction of this bug on my website
Source
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>AOM Repro</title>
</head>
<body>
<header>
<h1>Shadow Inputs should be allowed to be aria-hidden</h1>
</header>
<main>
<p>In this demo, a form-associated custom element provides a range slider.
The <abbr title="form-associated custom element">FACE</abbr>' shadow root
contains a private input. The private input is meant to be transparent to
assistive technology, since the FACE exposes it's state to
<abbr title="assistive technology">AT</abbr> via <code>ElementInternals</code>.</p>
<p>We expect AT to report a single interactive control here, but instead find that the
Chrome dev tools accessibility panel lists two nested controls.</p>
<form>
<fieldset>
<legend>Using <code>aria-hidden="true"</code></legend>
<label>Range <x-range template-type="aria-hidden"></x-range> </label>
</fieldset>
<fieldset>
<legend>Using <code>role="presentation"</code></legend>
<label>Range <x-range template-type="presentation"></x-range> </label>
</fieldset>
</form>
</main>
<template id="aria-hidden">
<input type="range" aria-hidden="true" min="0" max="100" step="1">
</template>
<template id="presentation">
<input type="range" role="presentation" min="0" max="100" step="1">
</template>
<script>
customElements.define('x-range', class extends HTMLElement {
static formAssociated = true;
#internals = this.attachInternals();
#input;
constructor() {
super();
this.#internals.role = 'slider';
const type = this.getAttribute('template-type');
this.attachShadow({ mode: 'open' })
.append(document.getElementById(type)
.content.cloneNode(true));
this.#input = this.shadowRoot.querySelector('input');
this.#input.addEventListener('change', e => this.#onChange(e))
if (this.hasAttribute('value')) this.#input.value = this.getAttribute('value');
this.#update();
}
#onChange(event) {
event.stopPropagation();
this.#update()
}
#update(value = this.#input.value) {
this.#input.value = value;
this.#input.step = this.getAttribute('step') ?? '1'
this.#internals.ariaValueMin = this.getAttribute('min') ?? '0';
this.#internals.ariaValueMax = this.getAttribute('max') ?? '100';
this.#internals.ariaValueNow = this.#input.value;
}
});
</script>
</body>
</html>