Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
33e40af
fix(checkbox,radio): show keyboard focus indicator, including in mult…
ShaneK Jul 24, 2026
81736bf
fix(toggle): show keyboard focus indicator via focusable track ring
ShaneK Jul 24, 2026
a135348
fix(checkbox,radio,toggle): hide native focus ring on focused controls
ShaneK Jul 27, 2026
5953965
test(checkbox,toggle): capture focus indicator via real keyboard focu…
ShaneK Jul 27, 2026
1568d4a
chore(test): trying to minimize screenshot width
ShaneK Jul 27, 2026
409a0ba
chore(): add updated snapshots
Ionitron Jul 27, 2026
6a3c199
Bdocs(checkbox,radio): add multi-input item focus demo to item test p…
ShaneK Jul 27, 2026
084902b
Merge branch 'FW-7585-FW-7586' of github.com:ionic-team/ionic-framewo…
ShaneK Jul 27, 2026
a105ea7
test(checkbox,radio,toggle): add checked/md focus and multi-input ite…
ShaneK Jul 27, 2026
c00892d
chore(lint): fixing lint
ShaneK Jul 27, 2026
fc10735
chore(): add updated snapshots
Ionitron Jul 27, 2026
1c38112
Merge branch 'FW-7585-FW-7586' of github.com:ionic-team/ionic-framewo…
ShaneK Jul 27, 2026
910cddb
test(checkbox,radio): add multi-input item focus screenshots and fix …
ShaneK Jul 27, 2026
edcfb72
chore(): add updated snapshots
Ionitron Jul 27, 2026
f758350
Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-…
ShaneK Jul 28, 2026
3dfbf81
Merge remote-tracking branch 'origin/FW-7585-FW-7586' into FW-7585-FW…
ShaneK Jul 28, 2026
0c4b6a1
test(checkbox,radio): move in-item focus tests to item test dir
ShaneK Jul 28, 2026
9c4786a
refactor(checkbox,radio): dedupe focus styles and item input observer
ShaneK Jul 29, 2026
48c5474
refactor(checkbox,radio,toggle): dedupe focus styles into base scss
ShaneK Jul 29, 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
32 changes: 31 additions & 1 deletion core/src/components/checkbox/checkbox.scss
Comment thread
brandyscarney marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
@import "../../themes/ionic.globals";
@import "./checkbox.vars.scss";

// Checkbox
Expand Down Expand Up @@ -119,9 +118,17 @@ input {
display: none;
}

// The host is focusable, so the browser draws a native focus ring on it.
// Suppress it in favor of the keyboard focus indicator.
:host(:focus) {
outline: none;
}

.native-wrapper {
display: flex;

position: relative;

align-items: center;
}

Expand Down Expand Up @@ -152,6 +159,29 @@ input {
opacity: 0;
}

// Checkbox: Keyboard Focus
// ---------------------------------------------

:host(.ion-focused) .native-wrapper::after {
@include border-radius(50%);

display: block;
position: absolute;

width: 36px;
height: 36px;

transform: translate(-50%, -50%);

background: $checkbox-background-color-focused;

content: "";
opacity: 0.2;

inset-block-start: 50%;
inset-inline-start: 50%;
}

// Checkbox Bottom Content
// ----------------------------------------------------------------

Expand Down
23 changes: 19 additions & 4 deletions core/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core';
import { checkInvalidState } from '@utils/forms';
import { Build, Component, Element, Event, Host, Method, Prop, State, forceUpdate, h } from '@stencil/core';
import { checkInvalidState, createItemMultipleInputsObserver } from '@utils/forms';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
import { createColorClasses, hostContext } from '@utils/theme';
Expand Down Expand Up @@ -37,6 +37,7 @@ export class Checkbox implements ComponentInterface {
private errorTextId = `${this.inputId}-error-text`;
private inheritedAttributes: Attributes = {};
private validationObserver?: MutationObserver;
private itemFocusObserver?: MutationObserver;

@Element() el!: HTMLIonCheckboxElement;

Expand Down Expand Up @@ -199,6 +200,10 @@ export class Checkbox implements ComponentInterface {
// Always set initial state
this.isInvalid = checkInvalidState(el);
this.hasLabelContent = this.el.textContent !== '';

// Re-render when the item flips `item-multiple-inputs` so the focus
// indicator stays in sync.
this.itemFocusObserver = createItemMultipleInputsObserver(el, () => forceUpdate(this));
}

componentWillLoad() {
Expand All @@ -210,11 +215,15 @@ export class Checkbox implements ComponentInterface {
}

disconnectedCallback() {
// Clean up validation observer to prevent memory leaks.
// Clean up observers to prevent memory leaks.
if (this.validationObserver) {
this.validationObserver.disconnect();
this.validationObserver = undefined;
}
if (this.itemFocusObserver) {
this.itemFocusObserver.disconnect();
this.itemFocusObserver = undefined;
}
}

/** @internal */
Expand Down Expand Up @@ -338,6 +347,8 @@ export class Checkbox implements ComponentInterface {
} = this;
const mode = getIonMode(this);
const path = getSVGPath(mode, indeterminate);
const inItem = hostContext('ion-item', el);
const inMultipleInputsItem = hostContext('ion-item.item-multiple-inputs', el);

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

Expand All @@ -360,11 +371,15 @@ export class Checkbox implements ComponentInterface {
onClick={this.onClick}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
'in-item': inItem,
'checkbox-checked': checked,
'checkbox-disabled': disabled,
'checkbox-indeterminate': indeterminate,
interactive: true,
// Focus styling should not apply when the checkbox is in an item,
// since the item handles the focus indicator instead. The exception
// is a multi-input item, which has no single indicator of its own.
'ion-focusable': !inItem || inMultipleInputsItem,
[`checkbox-justify-${justify}`]: justify !== undefined,
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
[`checkbox-label-placement-${labelPlacement}`]: true,
Expand Down
7 changes: 6 additions & 1 deletion core/src/components/checkbox/checkbox.vars.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
@import "../../themes/ionic.globals";

/// @prop - Top margin of checkbox's label when in an item
$checkbox-item-label-margin-top: 10px;

/// @prop - Bottom margin of checkbox's label when in an item
$checkbox-item-label-margin-bottom: 10px;
$checkbox-item-label-margin-bottom: 10px;

/// @prop - Background color of focus indicator for checkbox when focused
$checkbox-background-color-focused: ion-color(primary, tint);
64 changes: 64 additions & 0 deletions core/src/components/checkbox/test/a11y/checkbox.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,70 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title,
});
});

/**
* These tests assert the `ion-focusable` gating class the component controls,
* not the rendered focus ring. `ion-focused` is not asserted directly because
* it depends on keyboard-mode detection, which is unreliable on WebKit. The
* gating logic does not vary across modes.
*/
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
test.describe(title('checkbox: focus indicator'), () => {
test('standalone checkbox should be focusable', async ({ page }) => {
await page.setContent(
`
<ion-app>
<ion-checkbox aria-label="Checkbox">Checkbox</ion-checkbox>
</ion-app>
`,
config
);

const checkbox = page.locator('ion-checkbox');
await expect(checkbox).toHaveClass(/ion-focusable/);
});

test('checkbox in a single-input item should not show its own focus indicator', async ({ page }) => {
await page.setContent(
`
<ion-app>
<ion-item>
<ion-checkbox>Checkbox</ion-checkbox>
</ion-item>
</ion-app>
`,
config
);

// The item owns the focus indicator for single-input items, so the
// checkbox must not become focusable itself.
const checkbox = page.locator('ion-checkbox');
const item = page.locator('ion-item');
await expect(checkbox).not.toHaveClass(/ion-focusable/);
await expect(item).toHaveClass(/ion-focusable/);
});

test('checkbox in a multi-input item should be focusable', async ({ page }) => {
await page.setContent(
`
<ion-app>
<ion-item>
<ion-checkbox>Checkbox 1</ion-checkbox>
<ion-checkbox>Checkbox 2</ion-checkbox>
</ion-item>
</ion-app>
`,
config
);

// Multi-input items do not draw a single focus indicator, so each control
// must be able to show its own.
const checkboxes = page.locator('ion-checkbox');
await expect(checkboxes.nth(0)).toHaveClass(/ion-focusable/);
await expect(checkboxes.nth(1)).toHaveClass(/ion-focusable/);
});
});
});

configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
test.describe(title('checkbox: a11y'), () => {
test.describe(title('checkbox: font scaling'), () => {
Expand Down
121 changes: 78 additions & 43 deletions core/src/components/checkbox/test/basic/checkbox.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ configs().forEach(({ title, screenshot, config }) => {
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('checkbox: ionChange'), () => {
test('should fire ionChange when interacting with checkbox', async ({ page }) => {
await page.setContent(
Expand Down Expand Up @@ -138,48 +138,6 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
});

test.describe(title('checkbox: ionFocus'), () => {
Comment thread
brandyscarney marked this conversation as resolved.
test('should not have visual regressions', async ({ page, pageUtils }) => {
await page.setContent(
`
<style>
#container {
display: inline-block;
padding: 10px;
}
</style>

<div id="container">
<ion-checkbox>Unchecked</ion-checkbox>
</div>
`,
config
);

await pageUtils.pressKeys('Tab');

const container = page.locator('#container');

await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`));
});

test('should not have visual regressions when interacting with checkbox in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item class="ion-focused">
<ion-checkbox>Unchecked</ion-checkbox>
</ion-item>
`,
config
);

// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');

const item = page.locator('ion-item');

await expect(item).toHaveScreenshot(screenshot(`checkbox-in-item-focus`));
});

test('should fire ionFocus when checkbox is focused', async ({ page, pageUtils }) => {
await page.setContent(
`
Expand Down Expand Up @@ -328,3 +286,80 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c
});
});
});

/**
* This behavior does not vary across directions
*/
Comment on lines +290 to +292

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just a nit for consistency

Suggested change
/**
* The focus indicator UI differs between iOS and MD, so these visual tests run
* in both modes. Direction does not affect the indicator, so only LTR is run.
*/
/**
* This behavior does not vary across directions
*/

configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('checkbox: focus visual'), () => {
test('should render focus indicator when unchecked', async ({ page, pageUtils }) => {
// `ion-app` is required so `startFocusVisible` runs and applies the
// `ion-focused` class on keyboard focus, which drives the focus indicator.
await page.setContent(
`
<style>
#container {
width: fit-content;
padding: 10px;
}
</style>

<ion-app>
<div id="container">
<ion-checkbox>Unchecked</ion-checkbox>
</div>
</ion-app>
`,
config
);

const checkbox = page.locator('ion-checkbox');

// The focus listeners attach asynchronously, so the first Tab can miss
// them. Retry until `ion-focused` sticks before taking the snapshot.
await expect(async () => {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await pageUtils.pressKeys('Tab');
await expect(checkbox).toHaveClass(/ion-focused/, { timeout: 250 });
}).toPass({ timeout: 5000 });

const container = page.locator('#container');

await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`));
});

test('should render focus indicator when checked', async ({ page, pageUtils }) => {
await page.setContent(
`
<style>
#container {
width: fit-content;
padding: 10px;
}
</style>

<ion-app>
<div id="container">
<ion-checkbox checked>Checked</ion-checkbox>
</div>
</ion-app>
`,
config
);

const checkbox = page.locator('ion-checkbox');

// The focus listeners attach asynchronously, so the first Tab can miss
// them. Retry until `ion-focused` sticks before taking the snapshot.
await expect(async () => {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await pageUtils.pressKeys('Tab');
await expect(checkbox).toHaveClass(/ion-focused/, { timeout: 250 });
}).toPass({ timeout: 5000 });

const container = page.locator('#container');

await expect(container).toHaveScreenshot(screenshot(`checkbox-focus-checked`));
});
});
});
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.
Comment thread
brandyscarney marked this conversation as resolved.
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.
Binary file not shown.
Binary file not shown.
Loading