fix(radio): update to follow accessibility guidelines outlined by wai-aria (#22113)

This also fixes the Select "popover" interface as it is made up of radio buttons

WAI-ARIA Guidelines:

- Tab and Shift + Tab: Move focus into and out of the radio group. When focus moves into a radio group :
  - If a radio button is checked, focus is set on the checked button.
  - If none of the radio buttons are checked, focus is set on the first radio button in the group.
- Space: checks the focused radio button if it is not already checked.
- Right Arrow and Down Arrow: move focus to the next radio button in the group, uncheck the previously focused button, and check the newly focused button. If focus is on the last button, focus moves to the first button.
- Left Arrow and Up Arrow: move focus to the previous radio button in the group, uncheck the previously focused button, and check the newly focused button. If focus is on the first button, focus moves to the last button.

Closes #21743
This commit is contained in:
Brandy Carney
2020-09-24 14:33:27 -04:00
committed by GitHub
parent ca338864bf
commit ea0e0499e2
5 changed files with 127 additions and 5 deletions

View File

@ -1703,6 +1703,8 @@ export namespace Components {
* The name of the control, which is submitted with the form data.
*/
"name": string;
"setButtonTabindex": (value: number) => Promise<void>;
"setFocus": () => Promise<void>;
/**
* the value of the radio.
*/

View File

@ -1,4 +1,4 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, Watch, h } from '@stencil/core';
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Prop, Watch, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import { RadioGroupChangeEventDetail } from '../../interface';
@ -30,6 +30,8 @@ export class RadioGroup implements ComponentInterface {
@Watch('value')
valueChanged(value: any | undefined) {
this.setRadioTabindex(value);
this.ionChange.emit({ value });
}
@ -38,6 +40,31 @@ export class RadioGroup implements ComponentInterface {
*/
@Event() ionChange!: EventEmitter<RadioGroupChangeEventDetail>;
componentDidLoad() {
this.setRadioTabindex(this.value);
}
private setRadioTabindex = (value: any | undefined) => {
const radios = this.getRadios();
// Get the first radio that is not disabled and the checked one
const first = radios.find(radio => !radio.disabled);
const checked = radios.find(radio => (radio.value === value && !radio.disabled));
if (!first && !checked) {
return;
}
// If an enabled checked radio exists, set it to be the focusable radio
// otherwise we default to focus the first radio
const focusable = checked || first;
for (const radio of radios) {
const tabindex = radio === focusable ? 0 : -1;
radio.setButtonTabindex(tabindex);
}
}
async connectedCallback() {
// Get the list header if it exists and set the id
// this is used to set aria-labelledby
@ -51,6 +78,10 @@ export class RadioGroup implements ComponentInterface {
}
}
private getRadios(): HTMLIonRadioElement[] {
return Array.from(this.el.querySelectorAll('ion-radio'));
}
private onClick = (ev: Event) => {
const selectedRadio = ev.target && (ev.target as HTMLElement).closest('ion-radio');
if (selectedRadio) {
@ -64,6 +95,45 @@ export class RadioGroup implements ComponentInterface {
}
}
@Listen('keydown', { target: 'document' })
onKeydown(ev: any) {
if (ev.target && !this.el.contains(ev.target)) {
return;
}
// Get all radios inside of the radio group and then
// filter out disabled radios since we need to skip those
const radios = Array.from(this.el.querySelectorAll('ion-radio')).filter(radio => !radio.disabled);
// Only move the radio if the current focus is in the radio group
if (ev.target && radios.includes(ev.target)) {
const index = radios.findIndex(radio => radio === ev.target);
let next;
// If hitting arrow down or arrow right, move to the next radio
// If we're on the last radio, move to the first radio
if (['ArrowDown', 'ArrowRight'].includes(ev.key)) {
next = (index === radios.length - 1)
? radios[0]
: radios[index + 1];
}
// If hitting arrow up or arrow left, move to the previous radio
// If we're on the first radio, move to the last radio
if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) {
next = (index === 0)
? radios[radios.length - 1]
: radios[index - 1]
}
if (next && radios.includes(next)) {
next.setFocus();
this.value = next.value;
}
}
}
render() {
return (
<Host

View File

@ -1,4 +1,4 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, State, Watch, h } from '@stencil/core';
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import { Color, StyleEventDetail } from '../../interface';
@ -20,7 +20,7 @@ import { createColorClasses, hostContext } from '../../utils/theme';
shadow: true
})
export class Radio implements ComponentInterface {
private buttonEl?: HTMLButtonElement;
private inputId = `ion-rb-${radioButtonIds++}`;
private radioGroup: HTMLIonRadioGroupElement | null = null;
@ -31,6 +31,12 @@ export class Radio implements ComponentInterface {
*/
@State() checked = false;
/**
* The tabindex of the radio button.
* @internal
*/
@State() buttonTabindex = -1;
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@ -69,6 +75,20 @@ export class Radio implements ComponentInterface {
*/
@Event() ionBlur!: EventEmitter<void>;
/** @internal */
@Method()
async setFocus() {
if (this.buttonEl) {
this.buttonEl.focus();
}
}
/** @internal */
@Method()
async setButtonTabindex(value: number) {
this.buttonTabindex = value;
}
connectedCallback() {
if (this.value === undefined) {
this.value = this.inputId;
@ -117,7 +137,7 @@ export class Radio implements ComponentInterface {
}
render() {
const { inputId, disabled, checked, color, el } = this;
const { inputId, disabled, checked, color, el, buttonTabindex } = this;
const mode = getIonMode(this);
const labelId = inputId + '-lbl';
const label = findItemLabel(el);
@ -142,10 +162,12 @@ export class Radio implements ComponentInterface {
<div class="radio-inner" part="mark" />
</div>
<button
ref={btnEl => this.buttonEl = btnEl}
type="button"
onFocus={this.onFocus}
onBlur={this.onBlur}
disabled={disabled}
tabindex={buttonTabindex}
>
</button>
</Host>

View File

@ -20,6 +20,26 @@
</ion-header>
<ion-content id="content" class="radio-test outer-content">
<ion-list>
<ion-radio-group id="pizzaToppings">
<ion-item-divider>
<ion-label>Pizza Toppings (Unchecked Group)</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>Pepperoni</ion-label>
<ion-radio slot="start" value="pepperoni"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Sausage</ion-label>
<ion-radio slot="start" value="sausage"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Pineapple</ion-label>
<ion-radio slot="start" value="pineapple"></ion-radio>
</ion-item>
</ion-radio-group>
<ion-list>
<ion-radio-group id="fruitRadio" value="grape">
<ion-item-divider>
@ -77,6 +97,10 @@
</ion-radio-group>
<ion-radio-group allow-empty-selection value="custom">
<ion-item-divider>
<ion-label>Custom (Group w/ allow empty)</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>Custom</ion-label>
<ion-radio slot="end" color="danger" value="custom" style="--border-radius:2px;--inner-border-radius: 10px 0px 10px 0px;"></ion-radio>
@ -84,6 +108,10 @@
</ion-radio-group>
<ion-radio-group allow-empty-selection value="custom">
<ion-item-divider>
<ion-label>Part (Group w/ allow empty)</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>Radio ::part</ion-label>
<ion-radio slot="end" value="custom" class="radio-part"></ion-radio>

View File

@ -63,7 +63,7 @@ export const createOverlay = <T extends HTMLIonOverlayElement>(tagName: string,
return Promise.resolve() as any;
};
const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input:not([type=hidden]), textarea, button, select, .ion-focusable';
const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input:not([type=hidden]):not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), select:not([tabindex^="-"]), .ion-focusable:not([tabindex^="-"])';
const innerFocusableQueryString = 'input:not([type=hidden]), textarea, button, select';
const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {