fix(alert): update to follow accessibility guidelines outlined by wai-aria (#22159)

This also fixes the Select "alert" interface as it uses this component

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 #21744
This commit is contained in:
Brandy Carney
2020-09-24 17:09:54 -04:00
committed by GitHub
parent 1526bdfb49
commit e9b2cc8453
3 changed files with 75 additions and 10 deletions

View File

@@ -36,6 +36,7 @@ export interface AlertInput {
max?: string | number;
cssClass?: string | string[];
attributes?: AlertInputAttributes | AlertTextareaAttributes;
tabindex?: number;
}
export interface AlertTextareaAttributes extends JSXBase.TextareaHTMLAttributes<HTMLTextAreaElement> {}

View File

@@ -1,4 +1,4 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, Watch, forceUpdate, h } from '@stencil/core';
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Method, Prop, Watch, forceUpdate, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import { AlertButton, AlertInput, AlertInputAttributes, AlertTextareaAttributes, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface } from '../../interface';
@@ -130,6 +130,59 @@ export class Alert implements ComponentInterface, OverlayInterface {
*/
@Event({ eventName: 'ionAlertDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>;
@Listen('keydown', { target: 'document' })
onKeydown(ev: any) {
const inputTypes = new Set(this.processedInputs.map(i => i.type));
// The only inputs we want to navigate between using arrow keys are the radios
// ignore the keydown event if it is not on a radio button
if (
!inputTypes.has('radio')
|| (ev.target && !this.el.contains(ev.target))
|| ev.target.classList.contains('alert-button'))
{
return;
}
// Get all radios inside of the radio group and then
// filter out disabled radios since we need to skip those
const query = this.el.querySelectorAll('.alert-radio') as NodeListOf<HTMLButtonElement>;
const radios = Array.from(query).filter(radio => !radio.disabled);
// The focused radio is the one that shares the same id as
// the event target
const index = radios.findIndex(radio => radio.id === ev.target.id);
// We need to know what the next radio element should
// be in order to change the focus
let nextEl: HTMLButtonElement | undefined;
// 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)) {
nextEl = (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)) {
nextEl = (index === 0)
? radios[radios.length - 1]
: radios[index - 1]
}
if (nextEl && radios.includes(nextEl)) {
const nextProcessed = this.processedInputs.find(input => input.id === nextEl?.id);
if (nextProcessed) {
this.rbClick(nextProcessed);
nextEl.focus();
}
}
}
@Watch('buttons')
buttonsChanged() {
const buttons = this.buttons;
@@ -144,12 +197,21 @@ export class Alert implements ComponentInterface, OverlayInterface {
inputsChanged() {
const inputs = this.inputs;
// Get the first input that is not disabled and the checked one
// If an enabled checked input exists, set it to be the focusable input
// otherwise we default to focus the first input
// This will only be used when the input is type radio
const first = inputs.find(input => !input.disabled);
const checked = inputs.find(input => input.checked && !input.disabled);
const focusable = checked || first;
// An alert can be created with several different inputs. Radios,
// checkboxes and inputs are all accepted, but they cannot be mixed.
const inputTypes = new Set(inputs.map(i => i.type));
if (inputTypes.has('checkbox') && inputTypes.has('radio')) {
console.warn(`Alert cannot mix input types: ${(Array.from(inputTypes.values()).join('/'))}. Please see alert docs for more info.`);
}
this.inputType = inputTypes.values().next().value;
this.processedInputs = inputs.map((i, index) => ({
type: i.type || 'text',
@@ -165,6 +227,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
max: i.max,
cssClass: i.cssClass || '',
attributes: i.attributes || {},
tabindex: (i.type === 'radio' && i !== focusable) ? -1 : 0
}) as AlertInput);
}
@@ -241,6 +304,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
private rbClick(selectedInput: AlertInput) {
for (const input of this.processedInputs) {
input.checked = input === selectedInput;
input.tabindex = input === selectedInput ? 0 : -1;
}
this.activeId = selectedInput.id;
safeCall(selectedInput.handler, selectedInput);
@@ -333,7 +397,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
aria-checked={`${i.checked}`}
id={i.id}
disabled={i.disabled}
tabIndex={0}
tabIndex={i.tabindex}
role="checkbox"
class={{
...getClassMap(i.cssClass),
@@ -373,7 +437,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
aria-checked={`${i.checked}`}
disabled={i.disabled}
id={i.id}
tabIndex={0}
tabIndex={i.tabindex}
class={{
...getClassMap(i.cssClass),
'alert-radio-button': true,
@@ -411,7 +475,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
placeholder={i.placeholder}
value={i.value}
id={i.id}
tabIndex={0}
tabIndex={i.tabindex}
{...i.attributes as AlertTextareaAttributes}
disabled={i.attributes?.disabled ?? i.disabled}
class={inputClass(i)}
@@ -432,7 +496,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
max={i.max}
value={i.value}
id={i.id}
tabIndex={0}
tabIndex={i.tabindex}
{...i.attributes as AlertInputAttributes}
disabled={i.attributes?.disabled ?? i.disabled}
class={inputClass(i)}

View File

@@ -206,7 +206,6 @@
type: 'radio',
label: 'Radio 1',
value: 'value1',
checked: true
},
{
type: 'radio',
@@ -216,7 +215,8 @@
{
type: 'radio',
label: 'Radio 3',
value: 'value3'
value: 'value3',
checked: true
},
{
type: 'radio',
@@ -241,12 +241,12 @@
role: 'cancel',
cssClass: 'secondary',
handler: () => {
console.log('Confirm Cancel')
console.log('Confirm Cancel');
}
}, {
text: 'Ok',
handler: () => {
console.log('Confirm Ok')
handler: (ev) => {
console.log('Confirm Ok', ev);
}
}
]