From 8ee42bbc1e0bf4731d20040c7853756722f1a4b2 Mon Sep 17 00:00:00 2001 From: Tanner Reits <47483144+tanner-reits@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:28:22 -0500 Subject: [PATCH] fix(overlays): focus management with checkbox/radio (#30026) Issue number: resolves internal --------- ## What is the current behavior? Using `Tab` or `Shift + Tab` to focus through elements in a modal won't behave as expected when using `ion-checkbox` or `ion-radio` within an `ion-item`. Previously, the behavior would result in the last item in a list getting focus styling, but `document.activeElement` would still be the first actionable item in the overlay ## What is the new behavior? For checkboxes, the `ion-checkbox` element itself will be focused rather than the encapsulating `ion-item` For radios, the `ion-radio-group` will be used to focus the appropriate element. This will be the first `ion-radio` if there is no "checked" item, or the "checked" item if one exists. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information --- core/src/components.d.ts | 1 + core/src/components/radio-group/radio-group.tsx | 9 ++++++++- core/src/utils/focus-trap.ts | 10 ++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 2e5163329dd..1bdfaa88545 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2303,6 +2303,7 @@ export namespace Components { * The name of the control, which is submitted with the form data. */ "name": string; + "setFocus": () => Promise; /** * the value of the radio group. */ diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index 5d6036bc842..a8762b5f8a3 100644 --- a/core/src/components/radio-group/radio-group.tsx +++ b/core/src/components/radio-group/radio-group.tsx @@ -1,5 +1,5 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Listen, Prop, Watch, h } from '@stencil/core'; +import { Component, Element, Event, Host, Listen, Method, Prop, Watch, h } from '@stencil/core'; import { renderHiddenInput } from '@utils/helpers'; import { getIonMode } from '../../global/ionic-global'; @@ -217,6 +217,13 @@ export class RadioGroup implements ComponentInterface { } } + /** @internal */ + @Method() + async setFocus() { + const radioToFocus = this.getRadios().find((r) => r.tabIndex !== -1); + radioToFocus?.setFocus(); + } + render() { const { label, labelId, el, name, value } = this; const mode = getIonMode(this); diff --git a/core/src/utils/focus-trap.ts b/core/src/utils/focus-trap.ts index 1ac3d351ff7..918516c067b 100644 --- a/core/src/utils/focus-trap.ts +++ b/core/src/utils/focus-trap.ts @@ -13,7 +13,7 @@ import { focusVisibleElement } from '@utils/helpers'; * valid usage for the disabled property on ion-button. */ export const focusableQueryString = - '[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])'; + '[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), ion-checkbox:not([tabindex^="-"]):not([hidden]):not([disabled]), ion-radio:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .ion-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])'; /** * Focuses the first descendant in a context @@ -78,7 +78,13 @@ const focusElementInContext = ( } if (elementToFocus) { - focusVisibleElement(elementToFocus); + const radioGroup = elementToFocus.closest('ion-radio-group'); + + if (radioGroup) { + radioGroup.setFocus(); + } else { + focusVisibleElement(elementToFocus); + } } else { // Focus fallback element instead of letting focus escape fallbackElement.focus();