fix(radio): radios can be focused and are announced with group (#27817)

Issue number: resolves #27438

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

There are a few issues with the modern radio syntax:

1. The native radio is inside the Shadow DOM. As a result, radios are
not announced with their parent group with screen readers (i.e. "1 of
3")
2. The native radio cannot be focused inside of `ion-select-popover` on
Firefox.
3. The `ionFocus` and `ionBlur` events do not fire.

I also discovered an issue with item:

1. Items inside of a Radio Group have a role of `listitem` which prevent
radios from being grouped correctly in some browsers. According to
https://bugzilla.mozilla.org/show_bug.cgi?id=1840916, browsers are
behaving correctly here. The `listitem` role should not be present when
an item is used in a radio group (even if the radio group itself is
inside a list).

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

Most of the changes are test-related, but I broke it down per commit to
make this easier to review:


ae77002afd

- Item no longer has `role="listitem"` when used inside of a radio
group.
- Added spec tests to verify the role behavior


0a9b7fb91d

- I discovered that some the legacy basic test were accidentally using
the modern syntax. I corrected this by adding `legacy="true"` to the
radios.


a8a90e53b2,
412d1d54e7,
and
1d1179b69a

- The current radio group tests only tested the legacy radio syntax, and
not the modern syntax.
- I created a `legacy` directory to house the legacy syntax tests.
- I created new tests in the root test directory for the modern syntax.
- I also deleted the screenshots for the modern tests here because the
tests for `ion-radio` already take screenshots of the radio (even in an
item).


e2c966e68b
- Moved radio roles to the host. This allows Firefox to focus radios and
for screen readers to announce the radios as part of a group.
- I also added focus/blur listeners so ionFocus and ionBlur fire


f10eff47a5

- I cleaned up the tests here to use a common radio fixture

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

I tested this with the following setups.  indicates the screen reader
announces the group count (i.e. "1 of 4").  indicates the screen reader
does not announce the group count.

**Radio in Radio Group:**
- iOS + VoiceOver: 
- Android + TalkBack: 
- macOS + VoiceOver + Safari: 
- macOS + VoiceOver + Firefox: 
- macOS + VoiceOver + Chrome: 
- Windows + NVDA + Chrome: 
- Windows + NVDA + Firefox: 

**Radio in Item in Radio Group :**
- iOS + VoiceOver: 
- Android + TalkBack: 
(https://bugs.chromium.org/p/chromium/issues/detail?id=1459006)
- macOS + VoiceOver + Safari: 
- macOS + VoiceOver + Firefox: 
- macOS + VoiceOver + Chrome: 
(https://bugs.chromium.org/p/chromium/issues/detail?id=1459003)
- Windows + NVDA + Chrome: 
- Windows + NVDA + Firefox: 
This commit is contained in:
Liam DeBeasi
2023-07-31 10:07:44 -04:00
committed by GitHub
parent a08a5894ba
commit ba2f49b8a4
29 changed files with 664 additions and 154 deletions

View File

@ -398,7 +398,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
});
const ariaDisabled = disabled || childStyles['item-interactive-disabled'] ? 'true' : null;
const fillValue = fill || 'none';
const inList = hostContext('ion-list', this.el);
const inList = hostContext('ion-list', this.el) && !hostContext('ion-radio-group', this.el);
return (
<Host

View File

@ -0,0 +1,51 @@
import { Radio } from '../../../radio/radio.tsx';
import { RadioGroup } from '../../../radio-group/radio-group.tsx';
import { Item } from '../../item.tsx';
import { List } from '../../../list/list.tsx';
import { newSpecPage } from '@stencil/core/testing';
describe('ion-item', () => {
it('should not have a role when used without list', async () => {
const page = await newSpecPage({
components: [Item],
html: `<ion-item>Hello World</ion-item>`,
});
const item = page.body.querySelector('ion-item');
expect(item.getAttribute('role')).toBe(null);
});
it('should have a listitem role when used inside list', async () => {
const page = await newSpecPage({
components: [Item, List],
html: `
<ion-list>
<ion-item>
Hello World
</ion-item>
</ion-list>
`,
});
const item = page.body.querySelector('ion-item');
expect(item.getAttribute('role')).toBe('listitem');
});
it('should not have a role when used inside radio group and list', async () => {
const page = await newSpecPage({
components: [Radio, RadioGroup, Item, List],
html: `
<ion-list>
<ion-radio-group value="a">
<ion-item>
<ion-radio value="other-value" aria-label="my radio"></ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
`,
});
const item = page.body.querySelector('ion-item');
expect(item.getAttribute('role')).toBe(null);
});
});

View File

@ -30,23 +30,19 @@
</ion-list-header>
<ion-item>
<ion-label>Item 1</ion-label>
<ion-radio value="1" slot="start"></ion-radio>
<ion-radio value="1">Item 1</ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-radio value="2" slot="start"></ion-radio>
<ion-radio value="2">Item 2</ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-radio value="3" slot="start"></ion-radio>
<ion-radio value="3">Item 3</ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 4</ion-label>
<ion-radio value="4" slot="start"></ion-radio>
<ion-radio value="4">Item 4</ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>

View File

@ -1,19 +1,7 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2EPage } from '@utils/test/playwright';
configs().forEach(({ title, screenshot, config }) => {
test.describe(title('radio-group: basic'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.goto(`/src/components/radio-group/test/basic`, config);
const list = page.locator('ion-list');
await expect(list).toHaveScreenshot(screenshot(`radio-group-diff`));
});
});
});
import { RadioFixture } from '../fixtures';
/**
* This behavior does not vary across modes/directions.
@ -31,8 +19,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
<ion-radio id="one" value="one">One</ion-radio>
</ion-item>
</ion-radio-group>
`,
@ -48,8 +35,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
<ion-radio id="one" value="one">One</ion-radio>
</ion-item>
</ion-radio-group>
`,
@ -65,8 +51,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
<ion-radio id="one" value="one">One</ion-radio>
</ion-item>
</ion-radio-group>
`,
@ -82,8 +67,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
<ion-radio id="one" value="one">One</ion-radio>
</ion-item>
</ion-radio-group>
`,
@ -99,18 +83,15 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
`
<ion-radio-group value="1">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-radio value="1" slot="start"></ion-radio>
<ion-radio value="1">Item 1</ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-radio value="2" slot="start"></ion-radio>
<ion-radio value="2">Item 2</ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-radio value="3" slot="start"></ion-radio>
<ion-radio value="3">Item 3</ion-radio>
</ion-item>
</ion-radio-group>
`,
@ -130,34 +111,3 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
});
});
});
class RadioFixture {
readonly page: E2EPage;
private radio!: Locator;
constructor(page: E2EPage) {
this.page = page;
}
async checkRadio(method: 'keyboard' | 'mouse', selector = 'ion-radio') {
const { page } = this;
const radio = (this.radio = page.locator(selector));
if (method === 'keyboard') {
await radio.focus();
await page.keyboard.press('Space');
} else {
await radio.click();
}
await page.waitForChanges();
return radio;
}
async expectChecked(state: boolean) {
const { radio } = this;
await expect(radio.locator('input')).toHaveJSProperty('checked', state);
}
}

View File

@ -0,0 +1,39 @@
import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import type { E2EPage } from '@utils/test/playwright';
export class RadioFixture {
readonly page: E2EPage;
private radio!: Locator;
constructor(page: E2EPage) {
this.page = page;
}
async checkRadio(method: 'keyboard' | 'mouse', selector = 'ion-radio') {
const { page } = this;
const radio = (this.radio = page.locator(selector));
if (method === 'keyboard') {
await radio.focus();
await page.keyboard.press('Space');
} else {
await radio.click();
}
await page.waitForChanges();
return radio;
}
async expectChecked(state: boolean) {
const { radio } = this;
if (state) {
await expect(radio).toHaveClass(/radio-checked/);
} else {
await expect(radio).not.toHaveClass(/radio-checked/);
}
}
}

View File

@ -31,32 +31,19 @@
</ion-list-header>
<ion-item>
<ion-label
>Biff
<span id="biff"></span>
</ion-label>
<ion-radio value="biff" slot="start"></ion-radio>
<ion-radio value="biff">Biff</ion-radio>
</ion-item>
<ion-item>
<ion-label
>Griff
<span id="griff"></span>
</ion-label>
<ion-radio value="griff" slot="start"></ion-radio>
<ion-radio value="griff">Griff</ion-radio>
</ion-item>
<ion-item>
<ion-label
>Buford
<span id="buford"></span>
</ion-label>
<ion-radio value="buford" slot="start"></ion-radio>
<ion-radio value="buford">Buford</ion-radio>
</ion-item>
<ion-item>
<ion-label>George</ion-label>
<ion-radio value="george" disabled slot="start"></ion-radio>
<ion-radio value="george" disabled>George</ion-radio>
</ion-item>
<ion-item>

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Radio Group - Basic</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Radio Group - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-radio-group name="items" id="group" value="1">
<ion-list-header>
<ion-label>Radio Group Header</ion-label>
</ion-list-header>
<ion-item>
<ion-label>Item 1</ion-label>
<ion-radio value="1" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-radio value="2" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-radio value="3" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 4</ion-label>
<ion-radio value="4" slot="start"></ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,163 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { E2EPage } from '@utils/test/playwright';
configs().forEach(({ title, screenshot, config }) => {
test.describe(title('radio-group: basic'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.goto(`/src/components/radio-group/test/legacy/basic`, config);
const list = page.locator('ion-list');
await expect(list).toHaveScreenshot(screenshot(`radio-group-diff`));
});
});
});
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('radio-group: interaction'), () => {
let radioFixture: RadioFixture;
test.beforeEach(({ page }) => {
radioFixture = new RadioFixture(page);
});
test('spacebar should not deselect without allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('keyboard');
await radioFixture.expectChecked(true);
});
test('spacebar should deselect with allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('keyboard');
await radioFixture.expectChecked(false);
});
test('click should not deselect without allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="false">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('mouse');
await radioFixture.expectChecked(true);
});
test('click should deselect with allowEmptySelection', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="one" allow-empty-selection="true">
<ion-item>
<ion-label>One</ion-label>
<ion-radio id="one" value="one"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
await radioFixture.checkRadio('mouse');
await radioFixture.expectChecked(false);
});
test('programmatically assigning a value should update the checked radio', async ({ page }) => {
await page.setContent(
`
<ion-radio-group value="1">
<ion-item>
<ion-label>Item 1</ion-label>
<ion-radio value="1" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
<ion-radio value="2" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
<ion-radio value="3" slot="start"></ion-radio>
</ion-item>
</ion-radio-group>
`,
config
);
const radioGroup = page.locator('ion-radio-group');
const radioOne = page.locator('ion-radio[value="1"]');
const radioTwo = page.locator('ion-radio[value="2"]');
await radioGroup.evaluate((el: HTMLIonRadioGroupElement) => (el.value = '2'));
await page.waitForChanges();
await expect(radioOne).not.toHaveClass(/radio-checked/);
await expect(radioTwo).toHaveClass(/radio-checked/);
});
});
});
class RadioFixture {
readonly page: E2EPage;
private radio!: Locator;
constructor(page: E2EPage) {
this.page = page;
}
async checkRadio(method: 'keyboard' | 'mouse', selector = 'ion-radio') {
const { page } = this;
const radio = (this.radio = page.locator(selector));
if (method === 'keyboard') {
await radio.focus();
await page.keyboard.press('Space');
} else {
await radio.click();
}
await page.waitForChanges();
return radio;
}
async expectChecked(state: boolean) {
const { radio } = this;
await expect(radio.locator('input')).toHaveJSProperty('checked', state);
}
}

View File

@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Radio Group - Form</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Radio Group - Form</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="outer-content">
<form>
<ion-list>
<ion-radio-group name="tannen" id="group" value="biff">
<ion-list-header>
<ion-label>Luckiest Man On Earth</ion-label>
</ion-list-header>
<ion-item>
<ion-label
>Biff
<span id="biff"></span>
</ion-label>
<ion-radio value="biff" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label
>Griff
<span id="griff"></span>
</ion-label>
<ion-radio value="griff" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label
>Buford
<span id="buford"></span>
</ion-label>
<ion-radio value="buford" slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-label>George</ion-label>
<ion-radio value="george" disabled slot="start"></ion-radio>
</ion-item>
<ion-item>
<ion-button type="submit">Submit</ion-button>
</ion-item>
</ion-radio-group>
</ion-list>
</form>
<p style="margin: 20px">
Value:
<span id="value"></span>
</p>
<p style="margin: 20px">
Changes:
<span id="changes">0</span>
</p>
</ion-content>
<style>
.outer-content {
--background: #f2f2f2;
}
</style>
<script>
var changes = 0;
document.getElementById('group').addEventListener('ionChange', function (ev) {
document.getElementById('value').textContent = ev.detail.value;
changes++;
document.getElementById('changes').textContent = changes;
});
var biff = 0;
document.querySelector('[value="biff"]').addEventListener('ionSelect', function (ev) {
biff++;
document.getElementById('biff').textContent = biff;
});
var griff = 0;
document.querySelector('[value="griff"]').addEventListener('ionSelect', function (ev) {
griff++;
document.getElementById('griff').textContent = griff;
});
var buford = 0;
document.querySelector('[value="buford"]').addEventListener('ionSelect', function (ev) {
buford++;
document.getElementById('buford').textContent = buford;
});
</script>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,35 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('radio-group: form'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/radio-group/test/legacy/form', config);
});
test('selecting an option should update the value', async ({ page }) => {
const radioGroup = page.locator('ion-radio-group');
const ionChange = await page.spyOnEvent('ionChange');
const griffRadio = page.locator('ion-radio[value="griff"]');
await expect(radioGroup).toHaveAttribute('value', 'biff');
await griffRadio.click();
await page.waitForChanges();
await expect(ionChange).toHaveReceivedEventDetail({ value: 'griff', event: { isTrusted: true } });
});
test('selecting a disabled option should not update the value', async ({ page }) => {
const value = page.locator('#value');
const disabledRadio = page.locator('ion-radio[value="george"]');
await expect(value).toHaveText('');
await expect(disabledRadio).toHaveAttribute('disabled', '');
await disabledRadio.click({ force: true });
await page.waitForChanges();
await expect(value).toHaveText('');
});
});
});

View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Radio Group - Search</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Radio Group - Form</ion-title>
</ion-toolbar>
<ion-toolbar>
<ion-searchbar placeholder="Type to filter..." debounce="0"></ion-searchbar>
</ion-toolbar>
<ion-toolbar>
<ion-note id="value">Current value:</ion-note>
</ion-toolbar>
</ion-header>
<ion-content class="outer-content">
<ion-radio-group allow-empty-selection></ion-radio-group>
</ion-content>
<style>
ion-note {
padding: 0 10px;
}
</style>
<script>
const radioGroup = document.querySelector('ion-radio-group');
const searchbar = document.querySelector('ion-searchbar');
const currentValue = document.querySelector('#value');
radioGroup.addEventListener('ionChange', (ev) => {
currentValue.innerText = `Current value: ${ev.detail.value}`;
});
searchbar.addEventListener('ionChange', (ev) => {
filter(ev.detail.value);
});
const filter = (value) => {
const query = value != null ? value.toLowerCase() : '';
const data = [
{ id: 0, value: 'zero' },
{ id: 1, value: 'one' },
{ id: 2, value: 'two' },
{ id: 3, value: 'three' },
];
let html = '';
data.forEach((d) => {
if (d.value.includes(query)) {
html += `
<ion-item>
<ion-label>Item ${d.value}</ion-label>
<ion-radio value="${d.value}"></ion-radio>
</ion-item>
`;
}
});
radioGroup.innerHTML = html;
};
filter();
</script>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,42 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not var across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('radio-group'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/radio-group/test/legacy/search', config);
});
test.describe('radio-group: state', () => {
test('radio should remain checked after being removed/readded to the dom', async ({ page }) => {
const radioGroup = page.locator('ion-radio-group');
const radio = page.locator('ion-radio[value=two]');
const searchbarInput = page.locator('ion-searchbar input');
// select radio
await radio.click();
await expect(radio.locator('input')).toHaveJSProperty('checked', true);
// filter radio so it is not in DOM
await page.fill('ion-searchbar input', 'zero');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
await expect(radio).toBeHidden();
// ensure radio group has the same value
await expect(radioGroup).toHaveJSProperty('value', 'two');
// clear the search so the radio appears
await page.fill('ion-searchbar input', '');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
// ensure that the new radio instance is still checked
await expect(radio.locator('input')).toHaveJSProperty('checked', true);
});
});
});
});

View File

@ -65,8 +65,7 @@
if (d.value.includes(query)) {
html += `
<ion-item>
<ion-label>Item ${d.value}</ion-label>
<ion-radio value="${d.value}"></ion-radio>
<ion-radio value="${d.value}">Item ${d.value}</ion-radio>
</ion-item>
`;
}

View File

@ -1,42 +1,41 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import { RadioFixture } from '../fixtures';
/**
* This behavior does not var across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('radio-group'), () => {
test.beforeEach(async ({ page }) => {
test.describe(title('radio-group: search'), () => {
test('radio should remain checked after being removed/readded to the dom', async ({ page }) => {
await page.goto('/src/components/radio-group/test/search', config);
});
test.describe('radio-group: state', () => {
test('radio should remain checked after being removed/readded to the dom', async ({ page }) => {
const radioGroup = page.locator('ion-radio-group');
const radio = page.locator('ion-radio[value=two]');
const searchbarInput = page.locator('ion-searchbar input');
const radioFixture = new RadioFixture(page);
// select radio
await radio.click();
await expect(radio.locator('input')).toHaveJSProperty('checked', true);
const radioGroup = page.locator('ion-radio-group');
const searchbarInput = page.locator('ion-searchbar input');
// filter radio so it is not in DOM
await page.fill('ion-searchbar input', 'zero');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
await expect(radio).toBeHidden();
// select radio
const radio = await radioFixture.checkRadio('mouse', 'ion-radio[value=two]');
await radioFixture.expectChecked(true);
// ensure radio group has the same value
await expect(radioGroup).toHaveJSProperty('value', 'two');
// filter radio so it is not in DOM
await page.fill('ion-searchbar input', 'zero');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
await expect(radio).toBeHidden();
// clear the search so the radio appears
await page.fill('ion-searchbar input', '');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
// ensure radio group has the same value
await expect(radioGroup).toHaveJSProperty('value', 'two');
// ensure that the new radio instance is still checked
await expect(radio.locator('input')).toHaveJSProperty('checked', true);
});
// clear the search so the radio appears
await page.fill('ion-searchbar input', '');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
// ensure that the new radio instance is still checked
await radioFixture.expectChecked(true);
});
});
});

View File

@ -2,8 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import type { LegacyFormController } from '@utils/forms';
import { createLegacyFormController } from '@utils/forms';
import type { Attributes } from '@utils/helpers';
import { addEventListener, getAriaLabel, inheritAriaAttributes, removeEventListener } from '@utils/helpers';
import { addEventListener, getAriaLabel, removeEventListener } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { createColorClasses, hostContext } from '@utils/theme';
@ -31,7 +30,6 @@ export class Radio implements ComponentInterface {
private radioGroup: HTMLIonRadioGroupElement | null = null;
private nativeInput!: HTMLInputElement;
private legacyFormController!: LegacyFormController;
private inheritedAttributes: Attributes = {};
// This flag ensures we log the deprecation warning at most once.
private hasLoggedDeprecationWarning = false;
@ -135,8 +133,7 @@ export class Radio implements ComponentInterface {
ev.stopPropagation();
ev.preventDefault();
const element = this.legacyFormController.hasLegacyControl() ? this.el : this.nativeInput;
element.focus();
this.el.focus();
}
/** @internal */
@ -167,12 +164,6 @@ export class Radio implements ComponentInterface {
componentWillLoad() {
this.emitStyle();
if (!this.legacyFormController.hasLegacyControl()) {
this.inheritedAttributes = {
...inheritAriaAttributes(this.el),
};
}
}
@Watch('checked')
@ -201,7 +192,34 @@ export class Radio implements ComponentInterface {
};
private onClick = () => {
this.checked = this.nativeInput.checked;
const { radioGroup, checked } = this;
/**
* The legacy control uses a native input inside
* of the radio host, so we can set this.checked
* to the state of the nativeInput. RadioGroup
* will prevent the native input from checking if
* allowEmptySelection="false" by calling ev.preventDefault().
*/
if (this.legacyFormController.hasLegacyControl()) {
this.checked = this.nativeInput.checked;
return;
}
/**
* The modern control does not use a native input
* inside of the radio host, so we cannot rely on the
* ev.preventDefault() behavior above. If the radio
* is checked and the parent radio group allows for empty
* selection, then we can set the checked state to false.
* Otherwise, the checked state should always be set
* to true because the checked state cannot be toggled.
*/
if (checked && radioGroup?.allowEmptySelection) {
this.checked = false;
} else {
this.checked = true;
}
};
private onFocus = () => {
@ -232,23 +250,14 @@ export class Radio implements ComponentInterface {
}
private renderRadio() {
const {
checked,
disabled,
inputId,
color,
el,
justify,
labelPlacement,
inheritedAttributes,
hasLabel,
buttonTabindex,
} = this;
const { checked, disabled, color, el, justify, labelPlacement, hasLabel, buttonTabindex } = this;
const mode = getIonMode(this);
const inItem = hostContext('ion-item', el);
return (
<Host
onFocus={this.onFocus}
onBlur={this.onBlur}
onClick={this.onClick}
class={createColorClasses(color, {
[mode]: true,
@ -261,21 +270,12 @@ export class Radio implements ComponentInterface {
'ion-activatable': !inItem,
'ion-focusable': !inItem,
})}
role="radio"
aria-checked={checked ? 'true' : 'false'}
aria-disabled={disabled ? 'true' : null}
tabindex={buttonTabindex}
>
<label class="radio-wrapper">
{/*
The native control must be rendered
before the visible label text due to https://bugs.webkit.org/show_bug.cgi?id=251951
*/}
<input
type="radio"
checked={checked}
disabled={disabled}
id={inputId}
tabindex={buttonTabindex}
ref={(nativeEl) => (this.nativeInput = nativeEl as HTMLInputElement)}
{...inheritedAttributes}
/>
<div
class={{
'label-text-wrapper': true,

View File

@ -16,10 +16,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
});
test.describe(title('radio: keyboard navigation'), () => {
test.beforeEach(async ({ page, skip }) => {
// TODO (FW-2979)
skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.');
test.beforeEach(async ({ page }) => {
await page.setContent(
`
<ion-app>

View File

@ -95,7 +95,7 @@ configs().forEach(({ title, screenshot, config }) => {
test('should apply color correctly', async ({ page }) => {
await page.setContent(
`
<ion-radio slot="start" value="pepperoni" color="success"></ion-radio>
<ion-radio legacy="true" value="pepperoni" color="success"></ion-radio>
`,
config
);
@ -138,7 +138,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test('radio should be checked when activated even without a radio group', async ({ page }) => {
await page.setContent(
`
<ion-radio slot="start" value="pepperoni"></ion-radio>
<ion-radio legacy="true" value="pepperoni"></ion-radio>
`,
config
);