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
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
849ffe0
fix(toggle): fixing toggle cut off
ShaneK Jul 29, 2026
8819e0a
fix(checkbox,item,toggle): correct focus indicator clipping, rtl, and…
ShaneK Jul 29, 2026
5ead5d0
Merge remote-tracking branch 'origin/main' into FW-7585-FW-7586
ShaneK Jul 29, 2026
a533028
fix(toggle): suppress focus indicator when the item draws it
ShaneK Jul 29, 2026
7e26c68
chore(): add updated snapshots
Ionitron 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
30 changes: 29 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,16 @@ input {
display: none;
}

// Hide the native focus ring since we draw our own focus indicator.
:host(:focus) {
outline: none;
}

.native-wrapper {
display: flex;

position: relative;

align-items: center;
}

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

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

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

display: block;
position: absolute;

width: $checkbox-focus-ring-size;
height: $checkbox-focus-ring-size;

background: $checkbox-background-color-focused;

content: "";
opacity: 0.2;

// Centered with insets because `transform` is physical and breaks in RTL.
inset-block-start: calc(50% - #{$checkbox-focus-ring-size} / 2);
inset-inline-start: calc(50% - #{$checkbox-focus-ring-size} / 2);
}

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

Expand Down
19 changes: 15 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,8 @@ export class Checkbox implements ComponentInterface {
// Always set initial state
this.isInvalid = checkInvalidState(el);
this.hasLabelContent = this.el.textContent !== '';

this.itemFocusObserver = createItemMultipleInputsObserver(el, () => forceUpdate(this));
}

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

disconnectedCallback() {
// Clean up validation observer 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 +344,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 +368,14 @@ 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,
// A single-input item has the input cover and draws the indicator itself.
// A multi-input item has no cover, so each control draws its own.
'ion-focusable': !inItem || inMultipleInputsItem,
[`checkbox-justify-${justify}`]: justify !== undefined,
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
[`checkbox-label-placement-${labelPlacement}`]: true,
Expand Down
9 changes: 8 additions & 1 deletion core/src/components/checkbox/checkbox.vars.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
@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);

$checkbox-focus-ring-size: 36px;
58 changes: 58 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,64 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title,
});
});

/**
* These assert `ion-focusable`, not the rendered ring, because `ion-focused`
* relies on keyboard-mode detection that is flaky on WebKit. Gating is mode-independent.
*/
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
);

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
);

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
110 changes: 66 additions & 44 deletions core/src/components/checkbox/test/basic/checkbox.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import { applyKeyboardFocus, configs, test } from '@utils/test/playwright';

configs().forEach(({ title, screenshot, config }) => {
test.describe(title('checkbox: basic visual tests'), () => {
Expand Down 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,67 @@ 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 }) => {
// `ion-app` is required so `startFocusVisible` applies `ion-focused` on keyboard focus.
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');

await applyKeyboardFocus(page, checkbox);

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

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

test('should render focus indicator when checked', async ({ page }) => {
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');

await applyKeyboardFocus(page, checkbox);

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
Loading