mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-19 19:57:22 +08:00
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:
2
core/src/components.d.ts
vendored
2
core/src/components.d.ts
vendored
@ -1703,6 +1703,8 @@ export namespace Components {
|
|||||||
* The name of the control, which is submitted with the form data.
|
* The name of the control, which is submitted with the form data.
|
||||||
*/
|
*/
|
||||||
"name": string;
|
"name": string;
|
||||||
|
"setButtonTabindex": (value: number) => Promise<void>;
|
||||||
|
"setFocus": () => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* the value of the radio.
|
* the value of the radio.
|
||||||
*/
|
*/
|
||||||
|
@ -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 { getIonMode } from '../../global/ionic-global';
|
||||||
import { RadioGroupChangeEventDetail } from '../../interface';
|
import { RadioGroupChangeEventDetail } from '../../interface';
|
||||||
@ -30,6 +30,8 @@ export class RadioGroup implements ComponentInterface {
|
|||||||
|
|
||||||
@Watch('value')
|
@Watch('value')
|
||||||
valueChanged(value: any | undefined) {
|
valueChanged(value: any | undefined) {
|
||||||
|
this.setRadioTabindex(value);
|
||||||
|
|
||||||
this.ionChange.emit({ value });
|
this.ionChange.emit({ value });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +40,31 @@ export class RadioGroup implements ComponentInterface {
|
|||||||
*/
|
*/
|
||||||
@Event() ionChange!: EventEmitter<RadioGroupChangeEventDetail>;
|
@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() {
|
async connectedCallback() {
|
||||||
// Get the list header if it exists and set the id
|
// Get the list header if it exists and set the id
|
||||||
// this is used to set aria-labelledby
|
// 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) => {
|
private onClick = (ev: Event) => {
|
||||||
const selectedRadio = ev.target && (ev.target as HTMLElement).closest('ion-radio');
|
const selectedRadio = ev.target && (ev.target as HTMLElement).closest('ion-radio');
|
||||||
if (selectedRadio) {
|
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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Host
|
<Host
|
||||||
|
@ -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 { getIonMode } from '../../global/ionic-global';
|
||||||
import { Color, StyleEventDetail } from '../../interface';
|
import { Color, StyleEventDetail } from '../../interface';
|
||||||
@ -20,7 +20,7 @@ import { createColorClasses, hostContext } from '../../utils/theme';
|
|||||||
shadow: true
|
shadow: true
|
||||||
})
|
})
|
||||||
export class Radio implements ComponentInterface {
|
export class Radio implements ComponentInterface {
|
||||||
|
private buttonEl?: HTMLButtonElement;
|
||||||
private inputId = `ion-rb-${radioButtonIds++}`;
|
private inputId = `ion-rb-${radioButtonIds++}`;
|
||||||
private radioGroup: HTMLIonRadioGroupElement | null = null;
|
private radioGroup: HTMLIonRadioGroupElement | null = null;
|
||||||
|
|
||||||
@ -31,6 +31,12 @@ export class Radio implements ComponentInterface {
|
|||||||
*/
|
*/
|
||||||
@State() checked = false;
|
@State() checked = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tabindex of the radio button.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
@State() buttonTabindex = -1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The color to use from your application's color palette.
|
* The color to use from your application's color palette.
|
||||||
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
|
* 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>;
|
@Event() ionBlur!: EventEmitter<void>;
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
@Method()
|
||||||
|
async setFocus() {
|
||||||
|
if (this.buttonEl) {
|
||||||
|
this.buttonEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
@Method()
|
||||||
|
async setButtonTabindex(value: number) {
|
||||||
|
this.buttonTabindex = value;
|
||||||
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (this.value === undefined) {
|
if (this.value === undefined) {
|
||||||
this.value = this.inputId;
|
this.value = this.inputId;
|
||||||
@ -117,7 +137,7 @@ export class Radio implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { inputId, disabled, checked, color, el } = this;
|
const { inputId, disabled, checked, color, el, buttonTabindex } = this;
|
||||||
const mode = getIonMode(this);
|
const mode = getIonMode(this);
|
||||||
const labelId = inputId + '-lbl';
|
const labelId = inputId + '-lbl';
|
||||||
const label = findItemLabel(el);
|
const label = findItemLabel(el);
|
||||||
@ -142,10 +162,12 @@ export class Radio implements ComponentInterface {
|
|||||||
<div class="radio-inner" part="mark" />
|
<div class="radio-inner" part="mark" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
ref={btnEl => this.buttonEl = btnEl}
|
||||||
type="button"
|
type="button"
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
tabindex={buttonTabindex}
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</Host>
|
</Host>
|
||||||
|
@ -20,6 +20,26 @@
|
|||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-content id="content" class="radio-test outer-content">
|
<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-list>
|
||||||
<ion-radio-group id="fruitRadio" value="grape">
|
<ion-radio-group id="fruitRadio" value="grape">
|
||||||
<ion-item-divider>
|
<ion-item-divider>
|
||||||
@ -77,6 +97,10 @@
|
|||||||
</ion-radio-group>
|
</ion-radio-group>
|
||||||
|
|
||||||
<ion-radio-group allow-empty-selection value="custom">
|
<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-item>
|
||||||
<ion-label>Custom</ion-label>
|
<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>
|
<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>
|
||||||
|
|
||||||
<ion-radio-group allow-empty-selection value="custom">
|
<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-item>
|
||||||
<ion-label>Radio ::part</ion-label>
|
<ion-label>Radio ::part</ion-label>
|
||||||
<ion-radio slot="end" value="custom" class="radio-part"></ion-radio>
|
<ion-radio slot="end" value="custom" class="radio-part"></ion-radio>
|
||||||
|
@ -63,7 +63,7 @@ export const createOverlay = <T extends HTMLIonOverlayElement>(tagName: string,
|
|||||||
return Promise.resolve() as any;
|
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 innerFocusableQueryString = 'input:not([type=hidden]), textarea, button, select';
|
||||||
|
|
||||||
const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
||||||
|
Reference in New Issue
Block a user