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 behavior0a9b7fb91d
- 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
, and1d1179b69a
- 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 firef10eff47a5
- 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: ✅
@ -398,7 +398,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
|||||||
});
|
});
|
||||||
const ariaDisabled = disabled || childStyles['item-interactive-disabled'] ? 'true' : null;
|
const ariaDisabled = disabled || childStyles['item-interactive-disabled'] ? 'true' : null;
|
||||||
const fillValue = fill || 'none';
|
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 (
|
return (
|
||||||
<Host
|
<Host
|
||||||
|
51
core/src/components/item/test/a11y/item.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
@ -30,23 +30,19 @@
|
|||||||
</ion-list-header>
|
</ion-list-header>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item 1</ion-label>
|
<ion-radio value="1">Item 1</ion-radio>
|
||||||
<ion-radio value="1" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item 2</ion-label>
|
<ion-radio value="2">Item 2</ion-radio>
|
||||||
<ion-radio value="2" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item 3</ion-label>
|
<ion-radio value="3">Item 3</ion-radio>
|
||||||
<ion-radio value="3" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item 4</ion-label>
|
<ion-radio value="4">Item 4</ion-radio>
|
||||||
<ion-radio value="4" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-radio-group>
|
</ion-radio-group>
|
||||||
</ion-list>
|
</ion-list>
|
||||||
|
@ -1,19 +1,7 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import type { Locator } from '@playwright/test';
|
|
||||||
import { configs, test } from '@utils/test/playwright';
|
import { configs, test } from '@utils/test/playwright';
|
||||||
import type { E2EPage } from '@utils/test/playwright';
|
|
||||||
|
|
||||||
configs().forEach(({ title, screenshot, config }) => {
|
import { RadioFixture } from '../fixtures';
|
||||||
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`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This behavior does not vary across modes/directions.
|
* 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-radio-group value="one" allow-empty-selection="false">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>One</ion-label>
|
<ion-radio id="one" value="one">One</ion-radio>
|
||||||
<ion-radio id="one" value="one"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-radio-group>
|
</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-radio-group value="one" allow-empty-selection="true">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>One</ion-label>
|
<ion-radio id="one" value="one">One</ion-radio>
|
||||||
<ion-radio id="one" value="one"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-radio-group>
|
</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-radio-group value="one" allow-empty-selection="false">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>One</ion-label>
|
<ion-radio id="one" value="one">One</ion-radio>
|
||||||
<ion-radio id="one" value="one"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-radio-group>
|
</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-radio-group value="one" allow-empty-selection="true">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>One</ion-label>
|
<ion-radio id="one" value="one">One</ion-radio>
|
||||||
<ion-radio id="one" value="one"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-radio-group>
|
</ion-radio-group>
|
||||||
`,
|
`,
|
||||||
@ -99,18 +83,15 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
|||||||
`
|
`
|
||||||
<ion-radio-group value="1">
|
<ion-radio-group value="1">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item 1</ion-label>
|
<ion-radio value="1">Item 1</ion-radio>
|
||||||
<ion-radio value="1" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item 2</ion-label>
|
<ion-radio value="2">Item 2</ion-radio>
|
||||||
<ion-radio value="2" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item 3</ion-label>
|
<ion-radio value="3">Item 3</ion-radio>
|
||||||
<ion-radio value="3" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-radio-group>
|
</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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
39
core/src/components/radio-group/test/fixtures.ts
Normal 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/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -31,32 +31,19 @@
|
|||||||
</ion-list-header>
|
</ion-list-header>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label
|
<ion-radio value="biff">Biff</ion-radio>
|
||||||
>Biff
|
|
||||||
<span id="biff"></span>
|
|
||||||
</ion-label>
|
|
||||||
<ion-radio value="biff" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label
|
<ion-radio value="griff">Griff</ion-radio>
|
||||||
>Griff
|
|
||||||
<span id="griff"></span>
|
|
||||||
</ion-label>
|
|
||||||
<ion-radio value="griff" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label
|
<ion-radio value="buford">Buford</ion-radio>
|
||||||
>Buford
|
|
||||||
<span id="buford"></span>
|
|
||||||
</ion-label>
|
|
||||||
<ion-radio value="buford" slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>George</ion-label>
|
<ion-radio value="george" disabled>George</ion-radio>
|
||||||
<ion-radio value="george" disabled slot="start"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
|
56
core/src/components/radio-group/test/legacy/basic/index.html
Normal 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>
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
114
core/src/components/radio-group/test/legacy/form/index.html
Normal 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>
|
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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>
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -65,8 +65,7 @@
|
|||||||
if (d.value.includes(query)) {
|
if (d.value.includes(query)) {
|
||||||
html += `
|
html += `
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Item ${d.value}</ion-label>
|
<ion-radio value="${d.value}">Item ${d.value}</ion-radio>
|
||||||
<ion-radio value="${d.value}"></ion-radio>
|
|
||||||
</ion-item>
|
</ion-item>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,41 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import { configs, test } from '@utils/test/playwright';
|
import { configs, test } from '@utils/test/playwright';
|
||||||
|
|
||||||
|
import { RadioFixture } from '../fixtures';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This behavior does not var across modes/directions.
|
* This behavior does not var across modes/directions.
|
||||||
*/
|
*/
|
||||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||||
test.describe(title('radio-group'), () => {
|
test.describe(title('radio-group: search'), () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test('radio should remain checked after being removed/readded to the dom', async ({ page }) => {
|
||||||
await page.goto('/src/components/radio-group/test/search', config);
|
await page.goto('/src/components/radio-group/test/search', config);
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('radio-group: state', () => {
|
const radioFixture = new RadioFixture(page);
|
||||||
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
|
const radioGroup = page.locator('ion-radio-group');
|
||||||
await radio.click();
|
const searchbarInput = page.locator('ion-searchbar input');
|
||||||
await expect(radio.locator('input')).toHaveJSProperty('checked', true);
|
|
||||||
|
|
||||||
// filter radio so it is not in DOM
|
// select radio
|
||||||
await page.fill('ion-searchbar input', 'zero');
|
const radio = await radioFixture.checkRadio('mouse', 'ion-radio[value=two]');
|
||||||
await searchbarInput.evaluate((el) => el.blur());
|
await radioFixture.expectChecked(true);
|
||||||
await page.waitForChanges();
|
|
||||||
await expect(radio).toBeHidden();
|
|
||||||
|
|
||||||
// ensure radio group has the same value
|
// filter radio so it is not in DOM
|
||||||
await expect(radioGroup).toHaveJSProperty('value', 'two');
|
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
|
// ensure radio group has the same value
|
||||||
await page.fill('ion-searchbar input', '');
|
await expect(radioGroup).toHaveJSProperty('value', 'two');
|
||||||
await searchbarInput.evaluate((el) => el.blur());
|
|
||||||
await page.waitForChanges();
|
|
||||||
|
|
||||||
// ensure that the new radio instance is still checked
|
// clear the search so the radio appears
|
||||||
await expect(radio.locator('input')).toHaveJSProperty('checked', true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
|
||||||
import type { LegacyFormController } from '@utils/forms';
|
import type { LegacyFormController } from '@utils/forms';
|
||||||
import { createLegacyFormController } from '@utils/forms';
|
import { createLegacyFormController } from '@utils/forms';
|
||||||
import type { Attributes } from '@utils/helpers';
|
import { addEventListener, getAriaLabel, removeEventListener } from '@utils/helpers';
|
||||||
import { addEventListener, getAriaLabel, inheritAriaAttributes, removeEventListener } from '@utils/helpers';
|
|
||||||
import { printIonWarning } from '@utils/logging';
|
import { printIonWarning } from '@utils/logging';
|
||||||
import { createColorClasses, hostContext } from '@utils/theme';
|
import { createColorClasses, hostContext } from '@utils/theme';
|
||||||
|
|
||||||
@ -31,7 +30,6 @@ export class Radio implements ComponentInterface {
|
|||||||
private radioGroup: HTMLIonRadioGroupElement | null = null;
|
private radioGroup: HTMLIonRadioGroupElement | null = null;
|
||||||
private nativeInput!: HTMLInputElement;
|
private nativeInput!: HTMLInputElement;
|
||||||
private legacyFormController!: LegacyFormController;
|
private legacyFormController!: LegacyFormController;
|
||||||
private inheritedAttributes: Attributes = {};
|
|
||||||
|
|
||||||
// This flag ensures we log the deprecation warning at most once.
|
// This flag ensures we log the deprecation warning at most once.
|
||||||
private hasLoggedDeprecationWarning = false;
|
private hasLoggedDeprecationWarning = false;
|
||||||
@ -135,8 +133,7 @@ export class Radio implements ComponentInterface {
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
const element = this.legacyFormController.hasLegacyControl() ? this.el : this.nativeInput;
|
this.el.focus();
|
||||||
element.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
@ -167,12 +164,6 @@ export class Radio implements ComponentInterface {
|
|||||||
|
|
||||||
componentWillLoad() {
|
componentWillLoad() {
|
||||||
this.emitStyle();
|
this.emitStyle();
|
||||||
|
|
||||||
if (!this.legacyFormController.hasLegacyControl()) {
|
|
||||||
this.inheritedAttributes = {
|
|
||||||
...inheritAriaAttributes(this.el),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch('checked')
|
@Watch('checked')
|
||||||
@ -201,7 +192,34 @@ export class Radio implements ComponentInterface {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private onClick = () => {
|
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 = () => {
|
private onFocus = () => {
|
||||||
@ -232,23 +250,14 @@ export class Radio implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderRadio() {
|
private renderRadio() {
|
||||||
const {
|
const { checked, disabled, color, el, justify, labelPlacement, hasLabel, buttonTabindex } = this;
|
||||||
checked,
|
|
||||||
disabled,
|
|
||||||
inputId,
|
|
||||||
color,
|
|
||||||
el,
|
|
||||||
justify,
|
|
||||||
labelPlacement,
|
|
||||||
inheritedAttributes,
|
|
||||||
hasLabel,
|
|
||||||
buttonTabindex,
|
|
||||||
} = this;
|
|
||||||
const mode = getIonMode(this);
|
const mode = getIonMode(this);
|
||||||
const inItem = hostContext('ion-item', el);
|
const inItem = hostContext('ion-item', el);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Host
|
<Host
|
||||||
|
onFocus={this.onFocus}
|
||||||
|
onBlur={this.onBlur}
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
class={createColorClasses(color, {
|
class={createColorClasses(color, {
|
||||||
[mode]: true,
|
[mode]: true,
|
||||||
@ -261,21 +270,12 @@ export class Radio implements ComponentInterface {
|
|||||||
'ion-activatable': !inItem,
|
'ion-activatable': !inItem,
|
||||||
'ion-focusable': !inItem,
|
'ion-focusable': !inItem,
|
||||||
})}
|
})}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={checked ? 'true' : 'false'}
|
||||||
|
aria-disabled={disabled ? 'true' : null}
|
||||||
|
tabindex={buttonTabindex}
|
||||||
>
|
>
|
||||||
<label class="radio-wrapper">
|
<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
|
<div
|
||||||
class={{
|
class={{
|
||||||
'label-text-wrapper': true,
|
'label-text-wrapper': true,
|
||||||
|
@ -16,10 +16,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe(title('radio: keyboard navigation'), () => {
|
test.describe(title('radio: keyboard navigation'), () => {
|
||||||
test.beforeEach(async ({ page, skip }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// TODO (FW-2979)
|
|
||||||
skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.');
|
|
||||||
|
|
||||||
await page.setContent(
|
await page.setContent(
|
||||||
`
|
`
|
||||||
<ion-app>
|
<ion-app>
|
||||||
|
@ -95,7 +95,7 @@ configs().forEach(({ title, screenshot, config }) => {
|
|||||||
test('should apply color correctly', async ({ page }) => {
|
test('should apply color correctly', async ({ page }) => {
|
||||||
await page.setContent(
|
await page.setContent(
|
||||||
`
|
`
|
||||||
<ion-radio slot="start" value="pepperoni" color="success"></ion-radio>
|
<ion-radio legacy="true" value="pepperoni" color="success"></ion-radio>
|
||||||
`,
|
`,
|
||||||
config
|
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 }) => {
|
test('radio should be checked when activated even without a radio group', async ({ page }) => {
|
||||||
await page.setContent(
|
await page.setContent(
|
||||||
`
|
`
|
||||||
<ion-radio slot="start" value="pepperoni"></ion-radio>
|
<ion-radio legacy="true" value="pepperoni"></ion-radio>
|
||||||
`,
|
`,
|
||||||
config
|
config
|
||||||
);
|
);
|
||||||
|