From 641d05e624a86ade0d78021bc6c9a5f54e5706da Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Fri, 22 May 2026 12:09:22 -0400
Subject: [PATCH 01/17] fix(overlays): properly focus elements in a sheet modal
---
.../components/modal/test/sheet/modal.e2e.ts | 69 +++++
.../components/select/test/basic/index.html | 1 +
.../select/test/basic/select.e2e.ts | 83 ++++++
core/src/utils/overlays.ts | 242 ++++++++++++++++--
4 files changed, 378 insertions(+), 17 deletions(-)
diff --git a/core/src/components/modal/test/sheet/modal.e2e.ts b/core/src/components/modal/test/sheet/modal.e2e.ts
index ffa89001d9b..8259f646b77 100644
--- a/core/src/components/modal/test/sheet/modal.e2e.ts
+++ b/core/src/components/modal/test/sheet/modal.e2e.ts
@@ -391,6 +391,75 @@ configs({ modes: ['ios', 'ionic-ios'], directions: ['ltr'] }).forEach(({ title,
await expect(dragHandle).toBeFocused();
});
+
+ test('it should preserve the last arrow-focused radio when tabbing', async ({ page, pageUtils }) => {
+ await page.goto('/src/components/modal/test/sheet', config);
+
+ await page.setContent(
+ `
+
+ Open
+
+
+
+ Options
+
+ Cancel
+
+
+
+
+
+
+
+ One
+
+
+ Two
+
+
+ Three
+
+
+
+
+
+
+
+ `,
+ config
+ );
+
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+
+ await page.click('#open-modal');
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('ion-modal');
+ const firstRadio = modal.locator('ion-radio').nth(0);
+ const secondRadio = modal.locator('ion-radio').nth(1);
+ const handle = modal.locator('.modal-handle');
+
+ await firstRadio.focus();
+ await expect(firstRadio).toBeFocused();
+
+ await pageUtils.pressKeys('ArrowDown');
+ await expect(secondRadio).toBeFocused();
+
+ await pageUtils.pressKeys('Tab');
+ await expect(handle).toBeFocused();
+ });
});
test.describe(title('sheet modal: drag events'), () => {
diff --git a/core/src/components/select/test/basic/index.html b/core/src/components/select/test/basic/index.html
index 1048e1db3e6..ce9370c52b6 100644
--- a/core/src/components/select/test/basic/index.html
+++ b/core/src/components/select/test/basic/index.html
@@ -348,6 +348,7 @@
header: 'Pizza Toppings are really long',
breakpoints: [0.5],
initialBreakpoint: 0.5,
+ handleBehavior: 'cycle',
};
customModalSelect.interfaceOptions = customModalSheetOptions;
diff --git a/core/src/components/select/test/basic/select.e2e.ts b/core/src/components/select/test/basic/select.e2e.ts
index 6b797740b96..27134ab2e91 100644
--- a/core/src/components/select/test/basic/select.e2e.ts
+++ b/core/src/components/select/test/basic/select.e2e.ts
@@ -419,6 +419,89 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
const modal = page.locator('ion-modal');
await expect(modal).toHaveScreenshot(screenshot(`select-basic-modal-scroll-to-selected`));
});
+
+ test('it should support keyboard focus cycling between list, handle, and cancel', async ({ page, pageUtils }) => {
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+
+ await page.click('#customModalSelect');
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('ion-modal');
+ const firstOption = modal.locator('.select-interface-option:first-of-type ion-radio');
+ const secondOption = modal.locator('.select-interface-option:nth-of-type(2) ion-radio');
+ const handle = modal.locator('.modal-handle');
+ const cancelButton = modal.getByRole('button', { name: 'Cancel' });
+
+ await expect(firstOption).toBeFocused();
+
+ // After moving focus with arrow keys, Tab should still visit the handle
+ // before the cancel button
+ await pageUtils.pressKeys('ArrowDown');
+ await expect(secondOption).toBeFocused();
+ await page.waitForChanges();
+ await pageUtils.pressKeys('Tab');
+ await expect(handle).toBeFocused();
+ await pageUtils.pressKeys('Shift+Tab');
+ await expect(secondOption).toBeFocused();
+
+ await pageUtils.pressKeys('ArrowUp');
+ await expect(firstOption).toBeFocused();
+
+ // Forward cycle: list option -> handle -> cancel -> list option
+ await pageUtils.pressKeys('Tab');
+ await expect(handle).toBeFocused();
+
+ await pageUtils.pressKeys('Tab');
+ await expect(cancelButton).toBeFocused();
+
+ await pageUtils.pressKeys('Tab');
+ await expect(firstOption).toBeFocused();
+
+ // Reverse cycle: list option -> cancel -> handle -> list option
+ await pageUtils.pressKeys('Shift+Tab');
+ await expect(cancelButton).toBeFocused();
+
+ await pageUtils.pressKeys('Shift+Tab');
+ await expect(handle).toBeFocused();
+
+ await pageUtils.pressKeys('Shift+Tab');
+ await expect(firstOption).toBeFocused();
+ });
+
+ test('it should tab through cancel using the last arrow-highlighted option', async ({ page, pageUtils }) => {
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+
+ await page.click('#customModalSelect');
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('ion-modal');
+ const thirdOption = modal.locator('.select-interface-option:nth-of-type(3) ion-radio');
+ const handle = modal.locator('.modal-handle');
+ const cancelButton = modal.getByRole('button', { name: 'Cancel' });
+
+ await pageUtils.pressKeys('ArrowDown');
+ await pageUtils.pressKeys('ArrowDown');
+ await expect(thirdOption).toBeFocused();
+ await page.waitForChanges();
+
+ await pageUtils.pressKeys('Tab');
+ await expect(handle).toBeFocused();
+
+ await pageUtils.pressKeys('Tab');
+ await expect(cancelButton).toBeFocused();
+
+ await pageUtils.pressKeys('Tab');
+ await expect(thirdOption).toBeFocused();
+
+ await pageUtils.pressKeys('Shift+Tab');
+ await expect(cancelButton).toBeFocused();
+
+ await pageUtils.pressKeys('Shift+Tab');
+ await expect(handle).toBeFocused();
+
+ await pageUtils.pressKeys('Shift+Tab');
+ await expect(thirdOption).toBeFocused();
+ });
});
});
});
diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts
index 4f24ef4efaf..7d4eddc2027 100644
--- a/core/src/utils/overlays.ts
+++ b/core/src/utils/overlays.ts
@@ -44,8 +44,126 @@ type OverlayWithFocusTrapProps = HTMLIonOverlayElement & {
backdropBreakpoint?: number;
};
+const OVERLAY_FOCUS_TRAP_SELECTOR = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover';
+const ION_SELECT_MODAL_SELECTOR = 'ion-select-modal';
+
+/**
+ * This is used to restore focus to the correct option when the user presses
+ * Shift+Tab after moving focus to the sheet handle.
+ */
+type OverlayWithSheetModalTrapState = HTMLIonOverlayElement & {
+ trapLastSheetOptionControl?: HTMLElement;
+};
+
+const isSelectModalOptionControl = (el: HTMLElement) => el.tagName === 'ION-RADIO' || el.tagName === 'ION-CHECKBOX';
+
+/**
+ * Returns the currently focused element for keyboard focus checks.
+ *
+ * Starts from `document.activeElement` (non-shadow / light DOM focus).
+ * If focus is inside one or more open shadow roots
+ * (e.g. native control inside `ion-radio`), walks through nested
+ * `shadowRoot.activeElement` values until the innermost focused node is reached.
+ */
+const getActiveElement = (ownerDoc: Document): HTMLElement | null => {
+ let active = ownerDoc.activeElement as HTMLElement | null;
+ if (!active) {
+ return null;
+ }
+ while (active.shadowRoot?.activeElement) {
+ active = active.shadowRoot.activeElement as HTMLElement;
+ }
+ return active;
+};
+
+/**
+ * Walks from a focused node (possibly deep inside shadow roots)
+ * up to the nearest `ion-radio` / `ion-checkbox` host.
+ */
+const getOptionControlHost = (active: HTMLElement | null): HTMLElement | null => {
+ let n: HTMLElement | null = active;
+ while (n) {
+ if (isSelectModalOptionControl(n)) {
+ return n;
+ }
+ const root = n.getRootNode();
+ if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {
+ n = root.host;
+ } else {
+ return null;
+ }
+ }
+ return null;
+};
+
+/**
+ * Sheet modals can have visual order that differs from DOM order.
+ * Without sorting, Tab can skip the handle after option traversal.
+ *
+ * Order: option controls (radio/checkbox) → sheet handle → end-slot
+ * header button. Any other focusables are appended after.
+ */
+const sortSheetModalFocusables = (overlay: HTMLElement, elements: HTMLElement[]): HTMLElement[] => {
+ const optionControls = elements.filter((el) => {
+ return overlay.contains(el) && isSelectModalOptionControl(el) && el.tabIndex >= 0;
+ });
+
+ const cancelControl = elements.find(
+ (el) =>
+ overlay.contains(el) &&
+ el.tagName === 'ION-BUTTON' &&
+ el.closest('ion-header ion-buttons[slot="end"]') !== null
+ );
+
+ const handleControl = elements.find((el) => el.classList.contains('modal-handle'));
+
+ const sortByGeometry = (els: HTMLElement[]) =>
+ [...els].sort((a, b) => {
+ const ra = a.getBoundingClientRect();
+ const rb = b.getBoundingClientRect();
+ const topDiff = ra.top - rb.top;
+ if (Math.abs(topDiff) > 1) {
+ return topDiff;
+ }
+ return ra.left - rb.left;
+ });
+
+ const ordered: HTMLElement[] = [];
+ ordered.push(...sortByGeometry(optionControls));
+ if (handleControl) {
+ ordered.push(handleControl);
+ }
+ if (cancelControl) {
+ ordered.push(cancelControl);
+ }
+
+ const used = new Set(ordered);
+ for (const el of elements) {
+ if (!used.has(el)) {
+ ordered.push(el);
+ }
+ }
+
+ return ordered;
+};
+
+/**
+ * Option controls in groups use a tabindex pattern where only one
+ * option is tabbable (`tabIndex="0"`) while the others are `-1`.
+ * This returns the index of that current tabbable option.
+ */
+const getTabbableOptionControlIndex = (
+ elements: HTMLElement[],
+ overlay: HTMLElement
+): number => {
+ return elements.findIndex((el) => {
+ return overlay.contains(el) && isSelectModalOptionControl(el) && el.tabIndex >= 0;
+ });
+};
+
/**
- * Determines if the overlay's backdrop is always blocking (no background interaction).
+ * Determines if the overlay's backdrop is always blocking
+ * (no background interaction).
* Returns false if showBackdrop=false or backdropBreakpoint > 0.
*/
const isBackdropAlwaysBlocking = (el: OverlayWithFocusTrapProps): boolean => {
@@ -184,10 +302,7 @@ const focusElementInOverlay = (hostToFocus: HTMLElement | null | undefined, over
* Should NOT include: Toast
*/
const trapKeyboardFocus = (ev: Event, doc: Document) => {
- const lastOverlay = getPresentedOverlay(
- doc,
- 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover'
- );
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
const target = ev.target as HTMLElement | null;
/**
@@ -384,6 +499,35 @@ const connectListeners = (doc: Document) => {
true
);
+ /**
+ * Remember which option control last received focus
+ * (arrows, click, or Tab). This pattern keeps `tabIndex=0` on the
+ * checked/first radio, so the Tab trap uses this when wrapping back
+ * into the list or focusing the option-group slot.
+ */
+ doc.addEventListener(
+ 'focusin',
+ (ev: FocusEvent) => {
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
+ if (!lastOverlay || lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) {
+ return;
+ }
+ const isSheetModal = lastOverlay.classList.contains('modal-sheet');
+ if (!isSheetModal) {
+ return;
+ }
+ const target = ev.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ const optionHost = getOptionControlHost(target);
+ if (optionHost && lastOverlay.contains(optionHost)) {
+ (lastOverlay as OverlayWithSheetModalTrapState).trapLastSheetOptionControl = optionHost;
+ }
+ },
+ true
+ );
+
// Listen for keydown events to intercept Tab navigation.
// This is needed for Safari and Firefox which may skip focusable
// elements or allow focus to escape the overlay.
@@ -394,15 +538,11 @@ const connectListeners = (doc: Document) => {
(ev: KeyboardEvent) => {
if (ev.key !== 'Tab' && ev.key !== 'Alt+Tab') return;
- const lastOverlay = getPresentedOverlay(
- doc,
- 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover'
- );
+ const lastOverlay = getPresentedOverlay(doc, OVERLAY_FOCUS_TRAP_SELECTOR);
if (!lastOverlay || lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) return;
- const activeElement = doc.activeElement as HTMLElement | null;
-
+ const activeElement = getActiveElement(doc);
if (activeElement === lastOverlay) {
ev.preventDefault();
focusFirstDescendant(lastOverlay);
@@ -420,18 +560,37 @@ const connectListeners = (doc: Document) => {
if (!isInsideOverlay) return;
// Get all focusable elements from both light and shadow DOM
- const allFocusable = [
+ let allFocusable = [
...lastOverlay.querySelectorAll(focusableQueryString),
...(lastOverlay.shadowRoot?.querySelectorAll(focusableQueryString) || []),
];
+ const selectModalEl = lastOverlay.querySelector(ION_SELECT_MODAL_SELECTOR);
+ const isSheetModal = lastOverlay.classList.contains('modal-sheet');
+
+ /**
+ * Some sheet modal content, including ion-select-modal,
+ * renders option containers as `ion-item.select-interface-option`.
+ * These can match focusable selectors in some builds but
+ * are not intended tab stops. Keep only true interactive
+ * controls so Tab can move from options to header
+ * controls/handle.
+ */
+ if (selectModalEl) {
+ allFocusable = allFocusable.filter((el) => !el.matches('ion-item.select-interface-option'));
+ }
+
+ if (isSheetModal) {
+ allFocusable = sortSheetModalFocusables(lastOverlay, allFocusable);
+ }
+
if (allFocusable.length === 0) {
ev.preventDefault();
return;
}
// Find current element's index (accounting for shadow DOM)
- const currentIndex = activeElement
+ let currentIndex = activeElement
? allFocusable.findIndex((el) => {
if (el === activeElement) return true;
if (el.shadowRoot?.contains(activeElement)) return true;
@@ -440,6 +599,31 @@ const connectListeners = (doc: Document) => {
})
: -1;
+ /**
+ * Radio/checkbox groups can move focus onto an option with
+ * `tabIndex=-1`, while another option still has `tabIndex=0`
+ * in the list. `findIndex` then yields -1 and Tab incorrectly
+ * wraps to `allFocusable[0]`.
+ * Treat focus on any option control inside the sheet modal
+ * as the same trap slot as the listed tabbable option
+ * (`tabIndex >= 0`) so Tab goes handle → Cancel, not back
+ * to the first radio.
+ */
+ if (currentIndex < 0 && isSheetModal && activeElement) {
+ const optionHost = getOptionControlHost(activeElement);
+ if (optionHost && lastOverlay.contains(optionHost)) {
+ const directIndex = allFocusable.indexOf(optionHost);
+ if (directIndex >= 0) {
+ currentIndex = directIndex;
+ } else {
+ const tabbableOptionIndex = getTabbableOptionControlIndex(allFocusable, lastOverlay);
+ if (tabbableOptionIndex >= 0) {
+ currentIndex = tabbableOptionIndex;
+ }
+ }
+ }
+ }
+
ev.preventDefault();
// Helper to focus an element, handling shadow DOM properly
@@ -455,21 +639,45 @@ const connectListeners = (doc: Document) => {
focusVisibleElement(element);
};
+ let nextIndex: number;
if (ev.shiftKey) {
// Shift+Tab: previous element, wrap to last if at first
if (currentIndex <= 0) {
- focusLastDescendant(lastOverlay);
+ nextIndex = allFocusable.length - 1;
} else {
- focusElement(allFocusable[currentIndex - 1]);
+ nextIndex = currentIndex - 1;
}
} else {
// Tab: next element, wrap to first if at last
if (currentIndex < 0 || currentIndex >= allFocusable.length - 1) {
- focusFirstDescendant(lastOverlay);
+ nextIndex = 0;
} else {
- focusElement(allFocusable[currentIndex + 1]);
+ nextIndex = currentIndex + 1;
+ }
+ }
+
+ const nextEl = allFocusable[nextIndex];
+
+ const overlayTrap = lastOverlay as OverlayWithSheetModalTrapState;
+ const tabbableOptionIndex =
+ isSheetModal ? getTabbableOptionControlIndex(allFocusable, lastOverlay) : -1;
+
+ /**
+ * The trap list only includes one tabbable option host
+ * (`tabIndex >= 0`), usually the checked/first radio.
+ * `focusin` tracks the real last-focused option; use it
+ * whenever Tab would focus that slot (wrap from Cancel,
+ * Shift+Tab from handle, etc.).
+ */
+ let focusTarget = nextEl;
+ if (isSheetModal && tabbableOptionIndex >= 0 && nextIndex === tabbableOptionIndex) {
+ const saved = overlayTrap.trapLastSheetOptionControl;
+ if (saved?.isConnected && lastOverlay.contains(saved) && saved !== nextEl) {
+ focusTarget = saved;
}
}
+
+ focusElement(focusTarget);
},
true
);
From 3278a01ea6a66986539294df585630fadd42404c Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Fri, 22 May 2026 12:52:53 -0400
Subject: [PATCH 02/17] style: lint
---
core/src/utils/overlays.ts | 12 +++---------
1 file changed, 3 insertions(+), 9 deletions(-)
diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts
index 7d4eddc2027..34b7b7ad3ea 100644
--- a/core/src/utils/overlays.ts
+++ b/core/src/utils/overlays.ts
@@ -110,9 +110,7 @@ const sortSheetModalFocusables = (overlay: HTMLElement, elements: HTMLElement[])
const cancelControl = elements.find(
(el) =>
- overlay.contains(el) &&
- el.tagName === 'ION-BUTTON' &&
- el.closest('ion-header ion-buttons[slot="end"]') !== null
+ overlay.contains(el) && el.tagName === 'ION-BUTTON' && el.closest('ion-header ion-buttons[slot="end"]') !== null
);
const handleControl = elements.find((el) => el.classList.contains('modal-handle'));
@@ -152,10 +150,7 @@ const sortSheetModalFocusables = (overlay: HTMLElement, elements: HTMLElement[])
* option is tabbable (`tabIndex="0"`) while the others are `-1`.
* This returns the index of that current tabbable option.
*/
-const getTabbableOptionControlIndex = (
- elements: HTMLElement[],
- overlay: HTMLElement
-): number => {
+const getTabbableOptionControlIndex = (elements: HTMLElement[], overlay: HTMLElement): number => {
return elements.findIndex((el) => {
return overlay.contains(el) && isSelectModalOptionControl(el) && el.tabIndex >= 0;
});
@@ -659,8 +654,7 @@ const connectListeners = (doc: Document) => {
const nextEl = allFocusable[nextIndex];
const overlayTrap = lastOverlay as OverlayWithSheetModalTrapState;
- const tabbableOptionIndex =
- isSheetModal ? getTabbableOptionControlIndex(allFocusable, lastOverlay) : -1;
+ const tabbableOptionIndex = isSheetModal ? getTabbableOptionControlIndex(allFocusable, lastOverlay) : -1;
/**
* The trap list only includes one tabbable option host
From 808c193e5e2a3759ffb273b9e9c91d6b9fae78d6 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Fri, 29 May 2026 14:10:18 -0400
Subject: [PATCH 03/17] fix(item): add the focus styles for ionic theme
---
core/src/components/item/item.ionic.scss | 4 ----
core/src/components/item/item.tsx | 7 ++++++-
core/src/components/select-modal/select-modal.ionic.scss | 5 -----
3 files changed, 6 insertions(+), 10 deletions(-)
diff --git a/core/src/components/item/item.ionic.scss b/core/src/components/item/item.ionic.scss
index 665f5346c69..a92ce14d843 100644
--- a/core/src/components/item/item.ionic.scss
+++ b/core/src/components/item/item.ionic.scss
@@ -114,10 +114,6 @@ slot[name="end"]::slotted(*) {
// Item in Select Modal
// --------------------------------------------------
-:host(.in-select-modal) {
- --background-focused: #{globals.$ion-bg-neutral-subtlest-press};
- --background-focused-opacity: 0;
-}
:host(.in-select-modal.ion-focused) .item-native {
--border-radius: #{globals.$ion-border-radius-400};
diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx
index 65d5d949260..fd5bc217a54 100644
--- a/core/src/components/item/item.tsx
+++ b/core/src/components/item/item.tsx
@@ -259,7 +259,12 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
private isFocusable(): boolean {
const focusableChild = this.el.querySelector('.ion-focusable');
- return this.canActivate() || focusableChild !== null;
+ // An item is focusable when it can receive keyboard focus: when it is
+ // clickable (has a `button` or `href`), when it has a single input cover
+ // (e.g. a radio or checkbox), or when it contains a focusable child.
+ // Focusable items get the `ion-focusable` class so the `ion-focused`
+ // class is applied while tabbing through them.
+ return this.isClickable() || this.hasCover() || focusableChild !== null;
}
private hasStartEl() {
diff --git a/core/src/components/select-modal/select-modal.ionic.scss b/core/src/components/select-modal/select-modal.ionic.scss
index ca137a075d3..575c1f85d04 100644
--- a/core/src/components/select-modal/select-modal.ionic.scss
+++ b/core/src/components/select-modal/select-modal.ionic.scss
@@ -15,11 +15,6 @@ ion-item {
--border-width: 0;
}
-ion-item.ion-focused::part(native)::after {
- // Your styles for the ::after pseudo element when ion-item is focused
- border: none;
-}
-
// Toolbar
// ----------------------------------------------------------------
From ceecf4dd55fd0147234e40054f4a70ddd454c824 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Fri, 29 May 2026 14:34:42 -0400
Subject: [PATCH 04/17] fix(select): hide the focus ring when initially opening
an interface
---
core/src/components/select/select.tsx | 74 +++++++++++++++------------
core/src/utils/helpers.ts | 14 +++++
2 files changed, 55 insertions(+), 33 deletions(-)
diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx
index cde4a52850f..8560d798701 100644
--- a/core/src/components/select/select.tsx
+++ b/core/src/components/select/select.tsx
@@ -4,7 +4,7 @@ import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h,
import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config';
import type { NotchController } from '@utils/forms';
import { compareOptions, createNotchController, isOptionSelected, checkInvalidState } from '@utils/forms';
-import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
+import { suppressFocusVisible, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays';
@@ -436,55 +436,63 @@ export class Select implements ComponentInterface {
// Add logic to scroll selected item into view before presenting
const scrollSelectedIntoView = () => {
const indexOfSelected = this.childOpts.findIndex((o) => o.value === this.value);
+
+ /**
+ * Determine which option to focus when the overlay opens: the selected
+ * option if the select has a value, otherwise the first enabled option.
+ */
+ let optionToFocus: HTMLElement | null = null;
+
if (indexOfSelected > -1) {
const selectedItem = overlay.querySelector(
`.select-interface-option:nth-of-type(${indexOfSelected + 1})`
);
+ /**
+ * If the option contains an `ion-radio` or `ion-checkbox`, focus
+ * that instead of the option element itself. This ensures that
+ * screen readers will announce the role and state of the element
+ * (e.g. "radio button, checked") rather than only the option text.
+ * Alert and action sheet options are plain buttons, so fall back to
+ * focusing the option element itself in those cases.
+ */
if (selectedItem) {
- /**
- * Browsers such as Firefox do not
- * correctly delegate focus when manually
- * focusing an element with delegatesFocus.
- * We work around this by manually focusing
- * the interactive element.
- * ion-radio and ion-checkbox are the only
- * elements that ion-select-popover uses, so
- * we only need to worry about those two components
- * when focusing.
- */
- const interactiveEl = selectedItem.querySelector('ion-radio, ion-checkbox') as
- | HTMLIonRadioElement
- | HTMLIonCheckboxElement
- | null;
+ const interactiveEl = selectedItem.querySelector('ion-radio, ion-checkbox');
if (interactiveEl) {
selectedItem.scrollIntoView({ block: 'nearest' });
- // Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
- // and removing `ion-focused` style
- interactiveEl.setFocus();
}
-
- focusVisibleElement(selectedItem);
+ optionToFocus = interactiveEl ?? selectedItem;
}
} else {
/**
* If no value is set then focus the first enabled option.
*/
- const firstEnabledOption = overlay.querySelector(
+ optionToFocus = overlay.querySelector(
'ion-radio:not(.radio-disabled), ion-checkbox:not(.checkbox-disabled)'
- ) as HTMLIonRadioElement | HTMLIonCheckboxElement | null;
-
- if (firstEnabledOption) {
- /**
- * Focus the option for the same reason as we do above.
- *
- * Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
- * and removing `ion-focused` style
- */
- firstEnabledOption.setFocus();
+ );
+ }
- focusVisibleElement(firstEnabledOption.closest('ion-item')!);
+ if (optionToFocus) {
+ /**
+ * ion-radio and ion-checkbox expose `setFocus`, which correctly
+ * delegates focus to their inner focusable element. This is needed
+ * because browsers such as Firefox do not delegate focus correctly
+ * when focusing an element with delegatesFocus. Other options are
+ * plain buttons that can be focused directly.
+ */
+ if (optionToFocus.matches('ion-radio, ion-checkbox')) {
+ (optionToFocus as HTMLIonRadioElement | HTMLIonCheckboxElement).setFocus();
+ } else {
+ optionToFocus.focus();
}
+
+ /**
+ * While the focus should be on the option so assistive technologies
+ * announce it, we do not want to show the focus ring when the overlay
+ * opens. The ring should only appear once the user navigates the
+ * overlay with the keyboard, which the focus-visible utility handles.
+ */
+ suppressFocusVisible();
}
};
diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts
index ca7d1f406c7..068281bbb1d 100644
--- a/core/src/utils/helpers.ts
+++ b/core/src/utils/helpers.ts
@@ -303,6 +303,20 @@ export const focusVisibleElement = (el: HTMLElement) => {
}
};
+/**
+ * Clears the keyboard focus ring (`ion-focused`) that the focus-visible
+ * utility may have applied to elements during a programmatic focus.
+ *
+ * Use this after moving DOM focus to an element for assistive technologies
+ * (e.g. when an overlay opens) where the option should be focused so screen
+ * readers announce it, but the focus ring should not be shown until the user
+ * navigates with the keyboard (Tab/Arrow). The focus-visible utility will add
+ * `ion-focused` again on the next keyboard-driven focus change.
+ */
+export const suppressFocusVisible = () => {
+ focusElements([]);
+};
+
/**
* This method is used to add a hidden input to a host element that contains
* a Shadow DOM. It does not add the input inside of the Shadow root which
From a3377a30e8ac969fae5e7731f2be9fd66fbd21b9 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Fri, 29 May 2026 17:53:34 -0400
Subject: [PATCH 05/17] test(select): add ionic theme to basic test
---
core/src/components/select/test/basic/select.e2e.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/src/components/select/test/basic/select.e2e.ts b/core/src/components/select/test/basic/select.e2e.ts
index 27134ab2e91..1a012f17fbe 100644
--- a/core/src/components/select/test/basic/select.e2e.ts
+++ b/core/src/components/select/test/basic/select.e2e.ts
@@ -8,7 +8,7 @@ import { configs, test } from '@utils/test/playwright';
* does not. The overlay rendering is already tested in the respective
* test files.
*/
-configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
+configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
test.describe(title('select: basic'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/select/test/basic', config);
From 17983b15c6a3950e02c64fbd102c9c7474b61a5b Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Fri, 29 May 2026 17:55:41 -0400
Subject: [PATCH 06/17] chore(): add updated snapshots
---
...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 0 -> 18038 bytes
...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 0 -> 26115 bytes
...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 0 -> 21513 bytes
...o-selected-ios-ltr-Mobile-Chrome-linux.png | Bin 23930 -> 23664 bytes
...-selected-ios-ltr-Mobile-Firefox-linux.png | Bin 35591 -> 35523 bytes
...o-selected-ios-ltr-Mobile-Safari-linux.png | Bin 25694 -> 25649 bytes
...to-selected-md-ltr-Mobile-Chrome-linux.png | Bin 18935 -> 18038 bytes
...o-selected-md-ltr-Mobile-Firefox-linux.png | Bin 26106 -> 26115 bytes
...to-selected-md-ltr-Mobile-Safari-linux.png | Bin 21529 -> 21513 bytes
...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 0 -> 35819 bytes
...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 0 -> 49242 bytes
...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 0 -> 44359 bytes
...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 0 -> 23095 bytes
...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 0 -> 30999 bytes
...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 0 -> 28319 bytes
...o-selected-ios-ltr-Mobile-Chrome-linux.png | Bin 19828 -> 19323 bytes
...-selected-ios-ltr-Mobile-Firefox-linux.png | Bin 31112 -> 31105 bytes
...o-selected-ios-ltr-Mobile-Safari-linux.png | Bin 25285 -> 25285 bytes
...to-selected-md-ltr-Mobile-Chrome-linux.png | Bin 18428 -> 18331 bytes
...o-selected-md-ltr-Mobile-Firefox-linux.png | Bin 27605 -> 27530 bytes
...to-selected-md-ltr-Mobile-Safari-linux.png | Bin 22670 -> 22665 bytes
...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 0 -> 25134 bytes
...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 0 -> 36646 bytes
...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 0 -> 33704 bytes
...o-selected-ios-ltr-Mobile-Chrome-linux.png | Bin 46350 -> 44555 bytes
...-selected-ios-ltr-Mobile-Firefox-linux.png | Bin 54472 -> 54484 bytes
...o-selected-ios-ltr-Mobile-Safari-linux.png | Bin 54170 -> 54148 bytes
...to-selected-md-ltr-Mobile-Chrome-linux.png | Bin 37640 -> 36035 bytes
...o-selected-md-ltr-Mobile-Firefox-linux.png | Bin 46320 -> 45873 bytes
...to-selected-md-ltr-Mobile-Safari-linux.png | Bin 44025 -> 44022 bytes
30 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png
create mode 100644 core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png
diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..3498eb9ddfcbde56770d74d113bdaeb2f3e84c89
GIT binary patch
literal 18038
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