A HowtoRadioGroup is a set of checkable buttons, where only one button may
be checked at a time. The HowtoRadioGroup element wraps a set of
HowtoRadioButton children and manages their checked states in response to
user keyboard actions such as pressing arrow keys to select the next radio
button, or if the user clicks with a mouse.
The HowtoRadioGroup uses a technique called roving
tabindex
to manage which HowtoRadioButton child is currently focusable. In a
nutshell, the currently focusable child will have a tabindex=0, and all
other children will have a tabindex=-1. This ensures that the RadioGroup
itself is only a single tab stop, and focus always lands on whichever child
is currently checked. In the case where no child is checked, focus will land
on the first HowtoRadioButton child in the HowtoRadioGroup.
The HowtoRadioGroup uses aria-checked=true to indicate the checked state
of its HowtoRadioButton children. Only one child may be set to
aria-checked=true. Note that unlike most boolean attributes in HTML,
boolean ARIA attributes take a literal string value of either "true" or
"false".
See: https://www.w3.org/TR/wai-aria-practices-1.1/#radiobutton
<fieldset>
<legend>Beverages:</legend>
<howto-radio-group id="radios">
<howto-radio-button>Water</howto-radio-button>
<howto-radio-button>Soda</howto-radio-button>
<howto-radio-button>Coffee</howto-radio-button>
</howto-radio-group>
</fieldset>
(function() {
const KEYCODE = {
DOWN: 40,
LEFT: 37,
RIGHT: 39,
SPACE: 32,
UP: 38,
HOME: 36,
END: 35,
};
const radioButtonTemplate = document.createElement('template');
radioButtonTemplate.innerHTML = `
<style>
:host {
display: inline-block;
position: relative;
cursor: default;
}
:host(:focus) {
outline: 0;
}
:host(:focus)::before {
box-shadow: 0 0 1px 2px #5B9DD9;
}
:host::before {
content: '';
display: block;
width: 10px;
height: 10px;
border: 1px solid black;
position: absolute;
left: -18px;
top: 3px;
border-radius: 50%;
}
:host([aria-checked="true"])::before {
background: red;
}
</style>
<slot></slot>
`;
class HowtoRadioButton extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.appendChild(radioButtonTemplate.content.cloneNode(true));
}
connectedCallback() {
if (!this.hasAttribute('role'))
this.setAttribute('role', 'radio');
if (!this.hasAttribute('tabindex'))
this.setAttribute('tabindex', -1);
}
}
window.customElements.define('howto-radio-button', HowtoRadioButton);
const radioGroupTemplate = document.createElement('template');
radioGroupTemplate.innerHTML = `
<style>
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 20px;
}
</style>
<slot></slot>
`;
class HowtoRadioGroup extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.appendChild(radioGroupTemplate.content.cloneNode(true));
}
connectedCallback() {
if (!this.hasAttribute('role'))
this.setAttribute('role', 'radiogroup');
let firstCheckedButton = this.checkedRadioButton;
if (firstCheckedButton) {
this._uncheckAll();
this._checkNode(firstCheckedButton);
} else {
this.querySelector('[role="radio"]').setAttribute('tabindex', 0);
}
this.addEventListener('keydown', this._onKeyDown);
this.addEventListener('click', this._onClick);
}
disconnectedCallback() {
this.removeEventListener('keydown', this._onKeyDown);
this.removeEventListener('click', this._onClick);
}
_onKeyDown(e) {
switch (e.keyCode) {
case KEYCODE.UP:
case KEYCODE.LEFT:
e.preventDefault();
this._setCheckedToPrevButton();
break;
case KEYCODE.DOWN:
case KEYCODE.RIGHT:
e.preventDefault();
this._setCheckedToNextButton();
break;
case KEYCODE.HOME:
e.preventDefault();
this._setChecked(this.firstRadioButton);
break;
case KEYCODE.END:
e.preventDefault();
this._setChecked(this.lastRadioButton);
break;
case KEYCODE.SPACE:
e.preventDefault();
if (e.target.tagName.toLowerCase() === 'howto-radio-button')
this._setChecked(e.target);
break;
default:
break;
}
}
get checkedRadioButton() {
return this.querySelector('[aria-checked="true"]');
}
get firstRadioButton() {
return this.querySelector('[role="radio"]:first-of-type');
}
get lastRadioButton() {
return this.querySelector('[role="radio"]:last-of-type');
}
_prevRadioButton(node) {
let prev = node.previousElementSibling;
while (prev) {
if (prev.getAttribute('role') === 'radio') {
return prev;
}
prev = prev.previousElementSibling;
}
return null;
}
_nextRadioButton(node) {
let next = node.nextElementSibling;
while (next) {
if (next.getAttribute('role') === 'radio') {
return next;
}
next = next.nextElementSibling;
}
return null;
}
_setCheckedToPrevButton() {
let checkedButton = this.checkedRadioButton || this.firstRadioButton;
if (checkedButton === this.firstRadioButton) {
this._setChecked(this.lastRadioButton);
} else {
this._setChecked(this._prevRadioButton(checkedButton));
}
}
_setCheckedToNextButton() {
let checkedButton = this.checkedRadioButton || this.firstRadioButton;
if (checkedButton === this.lastRadioButton) {
this._setChecked(this.firstRadioButton);
} else {
this._setChecked(this._nextRadioButton(checkedButton));
}
}
_setChecked(node) {
this._uncheckAll();
this._checkNode(node);
this._focusNode(node);
}
_uncheckAll() {
const radioButtons = this.querySelectorAll('[role="radio"]');
for (let i = 0; i < radioButtons.length; i++) {
let btn = radioButtons[i];
btn.setAttribute('aria-checked', 'false');
btn.tabIndex = -1;
}
}
_checkNode(node) {
node.setAttribute('aria-checked', 'true');
node.tabIndex = 0;
}
_focusNode(node) {
node.focus();
}
_onClick(e) {
if (e.target.getAttribute('role') === 'radio') {
this._setChecked(e.target);
}
}
}
window.customElements.define('howto-radio-group', HowtoRadioGroup);
})();