From ea0e0499e24865faad3d11f50f7037645f6cdcc8 Mon Sep 17 00:00:00 2001 From: Brandy Carney Date: Thu, 24 Sep 2020 14:33:27 -0400 Subject: [PATCH] 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 --- core/src/components.d.ts | 2 + .../components/radio-group/radio-group.tsx | 72 ++++++++++++++++++- core/src/components/radio/radio.tsx | 28 +++++++- .../components/radio/test/basic/index.html | 28 ++++++++ core/src/utils/overlays.ts | 2 +- 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 6c378ebb70..2aa816ffa0 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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; + "setFocus": () => Promise; /** * the value of the radio. */ diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index c4e58a79f1..5c86b3d5b4 100644 --- a/core/src/components/radio-group/radio-group.tsx +++ b/core/src/components/radio-group/radio-group.tsx @@ -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; + 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 ( ; + /** @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 {
diff --git a/core/src/components/radio/test/basic/index.html b/core/src/components/radio/test/basic/index.html index 412d88283a..74dcf63a26 100644 --- a/core/src/components/radio/test/basic/index.html +++ b/core/src/components/radio/test/basic/index.html @@ -20,6 +20,26 @@ + + + + Pizza Toppings (Unchecked Group) + + + Pepperoni + + + + + Sausage + + + + + Pineapple + + + @@ -77,6 +97,10 @@ + + Custom (Group w/ allow empty) + + Custom @@ -84,6 +108,10 @@ + + Part (Group w/ allow empty) + + Radio ::part diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 85847e6372..2346a4693a 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -63,7 +63,7 @@ export const createOverlay = (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) => {