Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
641d05e
fix(overlays): properly focus elements in a sheet modal
brandyscarney May 22, 2026
3278a01
style: lint
brandyscarney May 22, 2026
808c193
fix(item): add the focus styles for ionic theme
brandyscarney May 29, 2026
ceecf4d
fix(select): hide the focus ring when initially opening an interface
brandyscarney May 29, 2026
a3377a3
test(select): add ionic theme to basic test
brandyscarney May 29, 2026
17983b1
chore(): add updated snapshots
brandyscarney May 29, 2026
add3c34
test(select): add tests to verify interface focused behavior on click
brandyscarney Jun 1, 2026
31665fa
chore(): add updated snapshots
brandyscarney Jun 1, 2026
1aa5b7c
fix(select): suppress the focus visible for alert only
brandyscarney Jun 1, 2026
641419f
fix(checkbox): hide the native focus state to match radio
brandyscarney Jun 1, 2026
395987c
test(select): add tests for keyboard focus behavior and remove skips
brandyscarney Jun 1, 2026
ea55440
chore(): add updated snapshots
brandyscarney Jun 1, 2026
38259e2
fix(checkbox): do not add double focus in an item
brandyscarney Jun 1, 2026
4bd07bf
chore(): add updated snapshots
Ionitron Jun 1, 2026
7abfe13
Merge branch 'next' into FW-6922
brandyscarney Jun 2, 2026
1d0b267
chore(): add updated snapshots
Ionitron Jun 2, 2026
29e7e8e
chore(): revert snapshots
brandyscarney Jun 2, 2026
a11a5c9
fix(overlays): properly navigate between action sheet options
brandyscarney Jun 2, 2026
9e200ed
refactor(utils): update focus trap to be component-agnostic
brandyscarney Jun 12, 2026
272c7f7
docs(component-guide): add a section on the new overlay focus markers
brandyscarney Jun 12, 2026
f4a33db
Merge branch 'next' into FW-6922
brandyscarney Jun 12, 2026
7951ac3
refactor(item): remove old styles
brandyscarney Jun 12, 2026
26048b9
chore(): add updated snapshots
Ionitron Jun 12, 2026
86edcb5
chore(): add updated snapshots
brandyscarney Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/components/action-sheet/action-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
class="action-sheet-group"
ref={(el) => (this.groupEl = el)}
role={hasRadioButtons ? 'radiogroup' : undefined}
data-roving-focus={hasRadioButtons ? true : undefined}
>
{header !== undefined && (
<div
Expand Down
5 changes: 5 additions & 0 deletions core/src/components/checkbox/checkbox.common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@
width: auto;
}

// Hide the native focus outline.
:host(:focus) {
outline: none;
}

.checkbox-wrapper {
display: flex;

Expand Down
6 changes: 4 additions & 2 deletions core/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export class Checkbox implements ComponentInterface {
} = this;
const theme = getIonTheme(this);
const path = getSVGPath(theme, indeterminate);
const inItem = hostContext('ion-item', el);

renderHiddenInput(true, el, name, checked ? value : '', disabled);

Expand All @@ -375,10 +376,11 @@ export class Checkbox implements ComponentInterface {
onClick={this.onClick}
class={createColorClasses(color, {
[theme]: true,
'in-item': hostContext('ion-item', el),
'in-item': inItem,
'checkbox-checked': checked,
'checkbox-disabled': disabled,
'ion-focusable': true,
// Focus styling should not apply when the checkbox is in an item
'ion-focusable': !inItem,

@brandyscarney brandyscarney Jun 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches radio behavior. Otherwise when a checkbox in an item has focus (such as inside of a select popover) the item and checkbox will both show a focus indicator.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bringing checkbox to parity with radio here makes sense. One edge case worth confirming: in a multi-input item hasCover() is false, so the item won't carry ion-focusable either, and the checkbox was the .ion-focusable child the item relied on. Does a checkbox sitting next to a second input still show a focus ring somewhere when you tab to it? Radio has the same shape today so I don't think it's a blocker, just want to make sure it's intentional.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is indeed an issue but it is a bigger issue than I think this ticket should handle. After my change, an item with two checkboxes shows no focus state on the item or the individual checkboxes, but before this change it didn't show a focus state for the individual checkboxes either. Radio seems to have the same pre-existing issue.

The bigger problem here seems to be Checkbox lacks focus styles entirely for ios and md. Radio has them with the ion-focused class, see:

Radios

But Checkbox doesn't have any at all:

Checkboxes

As a result I have created two tickets to follow up on this:

  • Add checkbox focus indicator: FW-7585
  • Multiple inputs in item lack individual focus: FW-7586

'checkbox-indeterminate': indeterminate,
interactive: true,
[`checkbox-justify-${justify}`]: justify !== undefined,
Expand Down

@brandyscarney brandyscarney Jun 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was removed intentionally because we don't want to show the native focus when we are adding our own. We need to add some ion-focused styles to checkbox for ios and md to fix this. Should I make a follow-up?

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion core/src/components/item/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1693,6 +1693,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
aria-label="Activate to adjust the size of the dialog overlaying the screen"
onClick={isHandleCycle ? this.onHandleClick : undefined}
part="handle"
data-focus-order="1"
ref={(el) => (this.dragHandleEl = el)}
></button>
)}
Expand Down
69 changes: 69 additions & 0 deletions core/src/components/modal/test/sheet/modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`
<ion-app>
<ion-button id="open-modal">Open</ion-button>
<ion-modal trigger="open-modal">
<ion-header>
<ion-toolbar>
<ion-title>Options</ion-title>
<ion-buttons slot="end">
<ion-button id="cancel-button">Cancel</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-radio-group value="one">
<ion-item>
<ion-radio value="one">One</ion-radio>
</ion-item>
<ion-item>
<ion-radio value="two">Two</ion-radio>
</ion-item>
<ion-item>
<ion-radio value="three">Three</ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
</ion-content>
</ion-modal>
</ion-app>
<script>
const modal = document.querySelector('ion-modal');
const cancelButton = document.querySelector('#cancel-button');

modal.breakpoints = [0, 0.5, 1];
modal.initialBreakpoint = 0.5;
modal.handleBehavior = 'cycle';

cancelButton.addEventListener('click', () => {
modal.dismiss();
});
</script>
`,
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'), () => {
Expand Down
1 change: 1 addition & 0 deletions core/src/components/radio-group/radio-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ export class RadioGroup implements ComponentInterface {
aria-labelledby={label ? labelId : null}
aria-describedby={this.hintTextId}
aria-invalid={this.isInvalid ? 'true' : undefined}
data-roving-focus
onClick={this.onClick}
>
{this.renderHintText()}
Expand Down
1 change: 1 addition & 0 deletions core/src/components/radio/radio.common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ input {
box-sizing: border-box;
}

// Hide the native focus outline.
:host(:focus) {
outline: none;
}
Expand Down
12 changes: 0 additions & 12 deletions core/src/components/select-modal/select-modal.ionic.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,6 @@ ion-item {
--border-radius: #{globals.$ion-border-radius-400};
}

// TODO(): Remove this when the focus styles are added back to the interface
ion-item.ion-focused::part(native)::after {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was added to hide the focused state.

// Your styles for the ::after pseudo element when ion-item is focused
outline: none;
}

ion-item.ion-focused.item-checkbox-checked,
ion-item.ion-focused.item-radio-checked {
--background-focused: #{globals.$ion-bg-primary-subtle-default};
--background-focused-opacity: 1;
}

// Toolbar
// ----------------------------------------------------------------

Expand Down
8 changes: 7 additions & 1 deletion core/src/components/select-modal/select-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export class SelectModal implements ComponentInterface {
return (
<ion-item
lines="none"
data-focus-ignore
// TODO FW-4784
disabled={option.disabled}
class={{
Expand Down Expand Up @@ -210,6 +211,7 @@ export class SelectModal implements ComponentInterface {

return (
<ion-item
data-focus-ignore
// TODO FW-4784
disabled={option.disabled}
class={{
Expand Down Expand Up @@ -254,7 +256,11 @@ export class SelectModal implements ComponentInterface {
{this.header !== undefined && <ion-title>{this.header}</ion-title>}

<ion-buttons slot="end">
<ion-button aria-label={this.cancelIcon ? this.cancelText : undefined} onClick={() => this.closeModal()}>
<ion-button
aria-label={this.cancelIcon ? this.cancelText : undefined}
data-focus-order="2"
onClick={() => this.closeModal()}
>
{this.cancelIcon ? (
<ion-icon aria-hidden="true" slot="icon-only" icon={this.cancelButtonIcon}></ion-icon>
) : (
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 50 additions & 32 deletions core/src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -436,54 +436,72 @@ 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<HTMLElement>(
`.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<HTMLElement>('ion-radio, ion-checkbox') as
| HTMLIonRadioElement
| HTMLIonCheckboxElement
| null;
const interactiveEl = selectedItem.querySelector<HTMLElement>('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<HTMLElement>(
optionToFocus = overlay.querySelector<HTMLElement>(
'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();
if (optionToFocus) {
/**
* Focus the option directly (`setFocus()`/`focus()`) rather than
* with `focusVisibleElement()`. `focusVisibleElement()` forces the
* `ion-focused` focus ring on regardless of how the overlay was
* opened, whereas a plain focus lets the focus-visible utility
* decide: it shows the ring only when the overlay was opened with
* the keyboard and hides it for pointer opens.
*
* `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. When the option is
* a plain button it can be focused directly.
*/
if (optionToFocus.matches('ion-radio, ion-checkbox')) {
(optionToFocus as HTMLIonRadioElement | HTMLIonCheckboxElement).setFocus();
} else {
optionToFocus.focus();
}

focusVisibleElement(firstEnabledOption.closest('ion-item')!);
/**
* In the alert interface, when tabbing and pressing enter to open
* the select, the value that is currently selected flashes the
* focused state briefly before moving to its wrapper. This suppresses
* the focus visible state, so that the option will only show as
* focused when navigating with the keyboard.
*/
if (this.interface === 'alert') {
suppressFocusVisible();
}
}
};
Expand Down
1 change: 1 addition & 0 deletions core/src/components/select/test/basic/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@
header: 'Pizza Toppings are really long',
breakpoints: [0.5],
initialBreakpoint: 0.5,
handleBehavior: 'cycle',
};
customModalSelect.interfaceOptions = customModalSheetOptions;

Expand Down
Loading
Loading