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 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
|
||||
|
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-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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
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-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>
|
||||
|
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)) {
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
);
|
||||
|