Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/eleven-ads-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@siemens/ix': minor
'@siemens/ix-angular': minor
'@siemens/ix-react': minor
'@siemens/ix-vue': minor
---

Improve `ix-radio-group` and `ix-radio` to be aligned with w3c pattern
2 changes: 2 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2969,6 +2969,7 @@ export namespace Components {
* @default false
*/
"required": boolean;
"setCheckedState": (newChecked: boolean) => Promise<void>;
/**
* Value of the radio component
*/
Expand Down Expand Up @@ -3006,6 +3007,7 @@ export namespace Components {
* @default false
*/
"required"?: boolean;
"setCheckedToNextItem": (currentRadio: HTMLIxRadioElement, forward?: boolean) => Promise<void>;
/**
* Show helper, info, warning, error and valid text as tooltip
*/
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/components/checkbox/tests/checkbox.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { expect } from '@playwright/test';
import {
getFormValue,
preventFormSubmission,
regressionTest,
test,
} from '@utils/test';
import { expect } from '@playwright/test';

regressionTest(`form-ready`, async ({ mount, page }) => {
await mount(`<form><ix-checkbox name="my-field-name"></ix-checkbox></form>`);
Expand Down Expand Up @@ -119,3 +119,16 @@ test('Checkbox should not cause layout shift when checked', async ({
expect(newBounds.top).toBeCloseTo(initialBounds.top, 0);
expect(newBounds.left).toBeCloseTo(initialBounds.left, 0);
});

test.describe('accessibility', () => {
test('should expose aria-label for accessibility queries', async ({
mount,
page,
}) => {
await mount(`<ix-checkbox label="Accept Terms"></ix-checkbox>`);
const checkbox = page.getByRole('checkbox', { name: 'Accept Terms' });
await expect(checkbox).toBeVisible();
await checkbox.click();
await expect(checkbox).toHaveAttribute('aria-checked', 'true');
});
});
49 changes: 48 additions & 1 deletion packages/core/src/components/radio-group/radio-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export class RadiobuttonGroup
if (!this.value) {
return;
}

this.radiobuttonElements.forEach((radiobutton) => {
radiobutton.checked = radiobutton.value === this.value;
});
Expand All @@ -153,6 +154,16 @@ export class RadiobuttonGroup
}
radio.checked = false;
});

const hasCheckedRadio = this.isSomeRadioChecked();

for (const radio of this.radiobuttonElements) {
radio.tabIndex = radio.checked ? 0 : -1;
}

if (!hasCheckedRadio && this.radiobuttonElements.length > 0) {
this.radiobuttonElements[0].tabIndex = 0;
}
}

private hasNestedRequiredRadio() {
Expand All @@ -161,6 +172,10 @@ export class RadiobuttonGroup
);
}

private isSomeRadioChecked() {
return this.radiobuttonElements.some((radio) => radio.checked);
}

@Watch('value')
onValueChangeHandler(newValue: string) {
this.touched = true;
Expand Down Expand Up @@ -213,9 +228,41 @@ export class RadiobuttonGroup
return Promise.resolve(this.touched);
}

/** @internal */
@Method()
async setCheckedToNextItem(
currentRadio: HTMLIxRadioElement,
forward = true
): Promise<void> {
const { radiobuttonElements } = this;
const { length } = radiobuttonElements;
if (length <= 1) {
return;
}

const index = radiobuttonElements.indexOf(currentRadio);
const step = forward ? 1 : -1;
let nextIndex = (index + step + length) % length;

while (radiobuttonElements[nextIndex].disabled) {
if (nextIndex === index) {
return;
}
nextIndex = (nextIndex + step + length) % length;
}

const nextRadio = radiobuttonElements[nextIndex];
nextRadio.setCheckedState(true);
nextRadio.focus();
}

render() {
return (
<Host onIxBlur={() => (this.touched = true)} ref={this.groupRef}>
<Host
onIxBlur={() => (this.touched = true)}
ref={this.groupRef}
role="radiogroup"
>
<ix-field-wrapper
label={this.label}
helperText={this.helperText}
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/components/radio-group/test/radio-group.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,102 @@ regressionTest('disabled', async ({ mount, page }) => {
await expect(radioOption3).not.toBeEnabled();
await expect(radioOption3.locator('.checkmark')).not.toBeVisible();
});

regressionTest.describe('keyboard navigation', () => {
regressionTest('Initial', async ({ mount, page }) => {
await mount(
`
<ix-radio-group>
<ix-radio aria-label="Option 1" label="Option 1" value="option1"></ix-radio>
<ix-radio aria-label="Option 2" label="Option 2" value="option2"></ix-radio>
<ix-radio aria-label="Option 3" label="Option 3" value="option3"></ix-radio>
<ix-radio aria-label="Option 4" label="Option 4" value="option4"></ix-radio>
</ix-radio-group>
`
);
const firstRadio = page.getByLabel('Option 1');
await expect(firstRadio).toBeVisible();

await page.keyboard.press('Tab');
await expect(page.getByLabel('Option 1')).not.toBeChecked();

await page.keyboard.press('ArrowDown');
await expect(page.getByLabel('Option 1')).not.toBeChecked();
await expect(page.getByLabel('Option 2')).toBeChecked();
});

regressionTest('Initial checked', async ({ mount, page }) => {
await mount(
`
<ix-radio-group>
<ix-radio aria-label="Option 1" label="Option 1" value="option1"></ix-radio>
<ix-radio aria-label="Option 2" label="Option 2" value="option2" checked></ix-radio>
<ix-radio aria-label="Option 3" label="Option 3" value="option3"></ix-radio>
<ix-radio aria-label="Option 4" label="Option 4" value="option4"></ix-radio>
</ix-radio-group>
`
);
const firstRadio = page.getByLabel('Option 1');
await expect(firstRadio).toBeVisible();

await page.keyboard.press('Tab');
await expect(page.getByLabel('Option 1')).not.toBeChecked();
await expect(page.getByLabel('Option 2')).toBeChecked();
});

regressionTest('Tab skip to next focus element', async ({ mount, page }) => {
await mount(
`
<ix-radio-group>
<ix-radio aria-label="Option 1" label="Option 1" value="option1"></ix-radio>
<ix-radio aria-label="Option 2" label="Option 2" value="option2" checked></ix-radio>
<ix-radio aria-label="Option 3" label="Option 3" value="option3" disabled></ix-radio>
<ix-radio aria-label="Option 4" label="Option 4" value="option4"></ix-radio>
</ix-radio-group>

<ix-button aria-label="Focusable Element">Focusable Element</ix-button>
`
);
const firstRadio = page.getByLabel('Option 1');
await expect(firstRadio).toBeVisible();

await page.keyboard.press('Tab');
await expect(page.getByLabel('Option 1')).not.toBeChecked();
await expect(page.getByLabel('Option 2')).toBeChecked();

await page.keyboard.press('Tab');
await expect(page.getByLabel('Focusable Element')).toBeFocused();
});

regressionTest('arrow navigation', async ({ mount, page }) => {
await mount(
`
<ix-radio-group>
<ix-radio aria-label="Option 1" label="Option 1" value="option1"></ix-radio>
<ix-radio aria-label="Option 2" label="Option 2" value="option2" checked></ix-radio>
<ix-radio aria-label="Option 3" label="Option 3" value="option3" disabled></ix-radio>
<ix-radio aria-label="Option 4" label="Option 4" value="option4"></ix-radio>
</ix-radio-group>
`
);
const firstRadio = page.getByLabel('Option 1');
await expect(firstRadio).toBeVisible();

await page.keyboard.press('Tab');
await expect(page.getByLabel('Option 1')).not.toBeChecked();
await expect(page.getByLabel('Option 2')).toBeChecked();

await page.keyboard.press('ArrowDown');
await expect(page.getByLabel('Option 3')).not.toBeChecked();
await expect(page.getByLabel('Option 4')).toBeChecked();

await page.keyboard.press('ArrowRight');
await expect(page.getByLabel('Option 1')).toBeChecked();

await page.keyboard.press('ArrowUp');
await expect(page.getByLabel('Option 4')).toBeChecked();

await page.keyboard.press('ArrowLeft');
await expect(page.getByLabel('Option 2')).toBeChecked();
});
});
Loading
Loading