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) => {