Button Role
Component
A <button-role>
is another element decorator.
An element decorator augments the behavior of the element it decorates without interfering with styles or layout by using display: contents
.
<button-role>
ensures the decorated element implements the button role spec,
which includes use of ARIA attributes, focus behavior, and keyboard interaction.
If an aria-pressed
attribute is present on the decorated element,
this component will toggle that attribute accordingly.
A <button>
only allows phrasing content, which does not include certain elements such as divs.
<button-role>
allows us to give button-like behavior to any element.
The implementation of this component is heavily dependent on the fact that elements with display: contents;
are not focusable.
This means the component itself cannot fully implement the button
ARIA role, and instead implements it
on the decorated element.
const KEYCODE = {
ENTER: 13,
SPACE: 32,
}
class ButtonRole extends HTMLElement {
#wrappedElement
#mutationObserver
constructor() {
super();
this.style.display = 'contents';
}
connectedCallback() {
if (this.firstElementChild) {
this.#wrapElement(this.firstElementChild);
}
this.#mutationObserver = new MutationObserver(this.#onMutation);
this.#mutationObserver.observe(this, {childList: true})
}
disconnectedCallback() {
this.#mutationObserver.disconnect();
}
#wrapElement(el) {
el.setAttribute('role', 'button');
if (!el.hasAttribute('tabindex')) {
el.setAttribute('tabindex', '0');
}
el.addEventListener('click', this.#onClick);
el.addEventListener('keydown', this.#onKeyDown);
this.#wrappedElement = el;
}
#unwrapElement() {
this.#wrappedElement.removeEventListener('click', this.#onClick);
this.#wrappedElement.removeEventListener('keydown', this.#onKeyDown);
this.#wrappedElement = null;
}
#onMutation = () => {
if (this.firstElementChild !== this.#wrappedElement) {
this.#unwrapElement();
if (this.firstElementChild) {
this.#wrapElement(this.firstElementChild);
}
}
}
#onClick = () => {
this.#toggleAriaPressed();
}
#onKeyDown = (event) => {
switch (event.keyCode) {
case KEYCODE.ENTER:
case KEYCODE.SPACE:
event.target.click();
break;
}
}
#toggleAriaPressed() {
const pressed = this.firstElementChild.getAttribute('aria-pressed');
if (pressed) {
if (pressed === 'true') {
this.firstElementChild.setAttribute('aria-pressed', 'false')
}
if (pressed === 'false') {
this.firstElementChild.setAttribute('aria-pressed', 'true')
}
}
}
}
if (!window.customElements.get('button-role'))
window.customElements.define('button-role', ButtonRole);
Demo
<button-role>
<div id="toggleDemo" aria-pressed="false">not pressed</div>
</button-role>
<script>
toggleDemo.addEventListener('click', (event) => {
const content = event.target.textContent;
event.target.textContent = content.includes('not') ? 'pressed' : 'not pressed'
});
</script>
not pressed