From e9b2cc8453f5e1c45d44397df738f60ea5b32efd Mon Sep 17 00:00:00 2001 From: Brandy Carney Date: Thu, 24 Sep 2020 17:09:54 -0400 Subject: [PATCH] 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 --- core/src/components/alert/alert-interface.ts | 1 + core/src/components/alert/alert.tsx | 74 +++++++++++++++++-- .../components/alert/test/basic/index.html | 10 +-- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/core/src/components/alert/alert-interface.ts b/core/src/components/alert/alert-interface.ts index 5704b35e45..898ca322e8 100644 --- a/core/src/components/alert/alert-interface.ts +++ b/core/src/components/alert/alert-interface.ts @@ -36,6 +36,7 @@ export interface AlertInput { max?: string | number; cssClass?: string | string[]; attributes?: AlertInputAttributes | AlertTextareaAttributes; + tabindex?: number; } export interface AlertTextareaAttributes extends JSXBase.TextareaHTMLAttributes {} diff --git a/core/src/components/alert/alert.tsx b/core/src/components/alert/alert.tsx index 42fbca8098..89e1468477 100644 --- a/core/src/components/alert/alert.tsx +++ b/core/src/components/alert/alert.tsx @@ -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; + @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; + 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)} diff --git a/core/src/components/alert/test/basic/index.html b/core/src/components/alert/test/basic/index.html index a7d4847b91..df7afb9a31 100644 --- a/core/src/components/alert/test/basic/index.html +++ b/core/src/components/alert/test/basic/index.html @@ -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); } } ]