Mike Schreiber



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