Skip to content
Open
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
109 changes: 109 additions & 0 deletions playwright/cps-ui-kit/components/cps-radio-group.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { test, expect, type Page, type Locator } from '@playwright/test';

function example(page: Page, testId: string): Locator {
return page.getByTestId(testId);
}

function radioGroup(page: Page, testId: string): Locator {
return example(page, testId).getByRole('radiogroup');
}

test.describe('cps-radio-group', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/radio-group');
});

test.describe('Real native keyboard navigation skips disabled radios', () => {
test('ArrowDown from an enabled radio real-skips a disabled one via native grouping', async ({
page
}) => {
const group = example(page, 'partially-disabled-radio-group');
const option2 = group.getByRole('radio', { name: 'Option 2' });
const option4 = group.getByRole('radio', { name: 'Option 4' });

await option2.focus();
await page.keyboard.press('ArrowDown');

await expect(option4).toBeFocused();
await expect(option4).toBeChecked();
});
});

test.describe('Real form validation shows and clears a real error', () => {
test('selecting the wrong option shows a real error; selecting the right one clears it', async ({
page
}) => {
const group = radioGroup(page, 'required-radio-group');
const errorEl = group.getByTestId('cps-radio-group-error');

const option1 = group.getByRole('radio', { name: 'Option 1' });
await option1.focus();
await page.keyboard.press('Space');
await page.evaluate(() =>
(document.activeElement as HTMLElement)?.blur()
);

await expect(errorEl).toBeVisible();
await expect(errorEl).toHaveText('Only third option must be selected');
await expect(group).toHaveAttribute(
'aria-describedby',
(await errorEl.getAttribute('id')) ?? ''
);

const option3 = group.getByRole('radio', { name: 'Option 3' });
await option3.focus();
await page.keyboard.press('Space');
await page.evaluate(() =>
(document.activeElement as HTMLElement)?.blur()
);

await expect(errorEl).toHaveCount(0);
});
});

test.describe('Real inert blocks focus on unselected custom content', () => {
test('the nested control real-refuses focus until its radio is selected', async ({
page
}) => {
const group = example(page, 'custom-content-radio-group');
const customRadio = group.getByRole('radio', {
name: 'Custom option with inline selectors'
});
const nestedCombobox = group.getByRole('combobox', {
name: 'Select day'
});

await expect(customRadio).not.toBeChecked();

await nestedCombobox.focus();
await expect(nestedCombobox).not.toBeFocused();

await customRadio.click();
await expect(customRadio).toBeChecked();

await nestedCombobox.focus();
await expect(nestedCombobox).toBeFocused();
});
});

test.describe('Real hideDetails suppresses both a real hint and a real validation error', () => {
test('a hint stays real-absent untouched, and a real error stays real-absent once invalid', async ({
page
}) => {
const group = radioGroup(page, 'required-hidden-radio-group');

await expect(group.getByTestId('cps-radio-group-hint')).toHaveCount(0);

const option1 = group.getByRole('radio', { name: 'Option 1' });
await option1.focus();
await page.keyboard.press('Space');
await page.evaluate(() =>
(document.activeElement as HTMLElement)?.blur()
);

await expect(group).toHaveAttribute('aria-invalid', 'true');
await expect(group).not.toHaveAttribute('aria-describedby', /.+/);
await expect(group.getByTestId('cps-radio-group-error')).toHaveCount(0);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,30 @@
[tsCode]="examples.requiredRadioGroup.ts">
<form [formGroup]="form">
<cps-radio-group
data-testid="required-radio-group"
groupLabel="Radio group where 3rd option must be selected"
formControlName="requiredRadio"
[options]="options">
</cps-radio-group>
</form>
</app-code-example>

<app-code-example
label="Required radio group with hidden details"
[htmlCode]="examples.requiredHiddenRadioGroup.html"
[tsCode]="examples.requiredHiddenRadioGroup.ts">
<form [formGroup]="form">
<cps-radio-group
data-testid="required-hidden-radio-group"
groupLabel="Required radio group with hidden details"
formControlName="requiredRadioHidden"
[options]="options"
hint="This hint stays hidden"
[hideDetails]="true">
</cps-radio-group>
</form>
</app-code-example>

<app-code-example
label="Radio group with a tooltip"
[htmlCode]="examples.tooltipRadioGroup.html"
Expand Down Expand Up @@ -55,11 +72,23 @@
[htmlCode]="examples.partiallyDisabledRadioGroup.html"
[tsCode]="examples.partiallyDisabledRadioGroup.ts">
<cps-radio-group
data-testid="partially-disabled-radio-group"
groupLabel="Radio group with partially disabled options and targeted tooltips"
[options]="partiallyDisabledOptions">
</cps-radio-group>
</app-code-example>

<app-code-example
label="Radio group with a hint"
[htmlCode]="examples.hintRadioGroup.html"
[tsCode]="examples.hintRadioGroup.ts">
<cps-radio-group
groupLabel="Radio group with a hint"
[options]="options"
hint="Choose the option that best fits your use case">
</cps-radio-group>
</app-code-example>

<app-code-example
label="Radio group with two-way binding"
[htmlCode]="examples.twoWayBindingRadioGroup.html"
Expand All @@ -79,6 +108,7 @@
[htmlCode]="examples.customContentRadioGroup.html"
[tsCode]="examples.customContentRadioGroup.ts">
<cps-radio-group
data-testid="custom-content-radio-group"
groupLabel="Radio group with custom content"
class="custom-content-example"
[value]="2"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import {
AbstractControl,
FormsModule,
Expand Down Expand Up @@ -88,19 +88,17 @@ export class RadioPageComponent implements OnInit {

componentData = ComponentData;

// eslint-disable-next-line no-useless-constructor
constructor(private _formBuilder: UntypedFormBuilder) {}
private readonly _formBuilder = inject(UntypedFormBuilder);

ngOnInit() {
const requiredThirdValidators = [
Validators.required,
(control: AbstractControl): ValidationErrors | null =>
this._checkThirdSelected(control)
];
this.form = this._formBuilder.group({
requiredRadio: [
'',
[
Validators.required,
(control: AbstractControl): ValidationErrors | null =>
this._checkThirdSelected(control)
]
]
requiredRadio: ['', requiredThirdValidators],
requiredRadioHidden: ['', requiredThirdValidators]
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,194 +5,246 @@
{ label: 'Option 3', value: 'third' }
];`;

export const radioExamples: Record<string, { html: string; ts?: string }> = {
requiredRadioGroup: {
html: `
<form [formGroup]="form">
<cps-radio-group
groupLabel="Radio group where 3rd option must be selected"
formControlName="requiredRadio"
[options]="options">
</cps-radio-group>
</form>`,
ts: `
private readonly _formBuilder = inject(UntypedFormBuilder);

${radioOptionsTs.trim()}

form!: UntypedFormGroup;

ngOnInit() {
this.form = this._formBuilder.group({
requiredRadio: [
'',
[
Validators.required,
(control: AbstractControl): ValidationErrors | null =>
this._checkThirdSelected(control)
]
]
});
}

private _checkThirdSelected(control: AbstractControl): ValidationErrors | null {
const val = control.value;
if (!val) return null;

if (val !== 'third') {
return { mustSelectThird: 'Only third option must be selected' };
}
return null;
}`
},

requiredHiddenRadioGroup: {
html: `
<form [formGroup]="form">
<cps-radio-group
groupLabel="Required radio group with hidden details"
formControlName="requiredRadioHidden"
[options]="options"
hint="This hint stays hidden"
[hideDetails]="true">
</cps-radio-group>
</form>`,
ts: `
private readonly _formBuilder = inject(UntypedFormBuilder);

${radioOptionsTs.trim()}

form!: UntypedFormGroup;

ngOnInit() {
this.form = this._formBuilder.group({
requiredRadioHidden: [
'',
[
Validators.required,
(control: AbstractControl): ValidationErrors | null =>
this._checkThirdSelected(control)
]
]
});
}

private _checkThirdSelected(control: AbstractControl): ValidationErrors | null {
const val = control.value;
if (!val) return null;

if (val !== 'third') {
return { mustSelectThird: 'Only third option must be selected' };
}
return null;
}`
},

tooltipRadioGroup: {
html: `
<cps-radio-group
groupLabel="Radio group with a tooltip"
[options]="options"
infoTooltip="Provide any information here"
value="second">
</cps-radio-group>`,
ts: radioOptionsTs
},

verticalRadioGroup: {
html: `
<cps-radio-group
groupLabel="Vertical radio group"
[options]="options"
value="second"
[vertical]="true">
</cps-radio-group>`,
ts: radioOptionsTs
},

disabledVerticalRadioGroup: {
html: `
<cps-radio-group
groupLabel="Disabled vertical radio group"
[options]="options"
value="second"
[disabled]="true"
[vertical]="true">
</cps-radio-group>`,
ts: radioOptionsTs
},

partiallyDisabledRadioGroup: {
html: `
<cps-radio-group
groupLabel="Radio group with partially disabled options and targeted tooltips"
[options]="partiallyDisabledOptions">
</cps-radio-group>`,
ts: `
partiallyDisabledOptions: CpsRadioOption[] = [
{
label: 'Option 1',
value: 'first',
disabled: true,
tooltip: 'First option is currently unavailable'
},
{ label: 'Option 2', value: 'second' },
{
label: 'Option 3',
value: 'third',
disabled: true,
tooltip: 'Third option is currently unavailable'
},
{ label: 'Option 4', value: 'fourth' }
];`
},

hintRadioGroup: {
html: `
<cps-radio-group
groupLabel="Radio group with a hint"
[options]="options"
hint="Choose the option that best fits your use case">
</cps-radio-group>`,
ts: radioOptionsTs
},

twoWayBindingRadioGroup: {
html: `
<div class="sync-val-example">
<cps-radio-group
groupLabel="Radio group with two-way binding"
[options]="options"
[(ngModel)]="syncVal">
</cps-radio-group>
<div class="sync-val">Selected value: {{ syncVal }}</div>
</div>`,
ts: `
${radioOptionsTs.trim()}

syncVal = 'first';`
},

customContentRadioGroup: {
html: `
<cps-radio-group
groupLabel="Radio group with custom content"
[value]="2"
[vertical]="true">
<cps-radio
[option]="{
ariaLabel: 'Custom option with inline selectors',
value: 1
}">
<div style="display: flex; align-items: center; gap: 0.3125rem;">
<span>On the</span>
<cps-select
ariaLabel="Select day"
[options]="dayOptions"
optionLabel="name"
[hideDetails]="true"
[value]="dayOptions[0]">
</cps-select>
<span>of every</span>
<cps-select
ariaLabel="Select month"
[options]="monthOptions"
optionLabel="name"
[hideDetails]="true"
[value]="monthOptions[0]">
</cps-select>
<span>month(s) at</span>
<cps-select
ariaLabel="Select hour"
[options]="hourOptions"
optionLabel="name"
[hideDetails]="true"
[value]="hourOptions[0]">
</cps-select>
<span>:</span>
<cps-select
ariaLabel="Select minute"
[options]="minuteOptions"
optionLabel="name"
[hideDetails]="true"
[value]="minuteOptions[0]">
</cps-select>
<cps-checkbox
style="margin-left: 0.9375rem;"
label="During the nearest weekday"
[value]="false"></cps-checkbox>
</div>
</cps-radio>
<cps-radio [option]="{ label: 'Simple option', value: 2 }"></cps-radio>
</cps-radio-group>`,
ts: `
dayOptions = [
{ name: '1st day', data: { code: '1' } },
{ name: '2nd day', data: { code: '2' } },
{ name: '3rd day', data: { code: '3' } }
];

monthOptions = [...Array(12).keys()].map((n) => ({
name: n + 1,
data: { code: n + 1 }
}));

hourOptions = [...Array(24).keys()].map((n) => ({
name: n,
data: { code: n }
}));

minuteOptions = [...Array(60).keys()].map((n) => ({
name: n,
data: { code: n }
}));`
}
};

Check warning on line 250 in projects/composition/src/app/pages/radio-page/radio-page.examples.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@if (option.tooltip) {
<div
class="cps-radio-group-content-button"
data-testid="cps-radio-button"
[cpsTooltip]="option.tooltip || ''"
tooltipCloseDelay="0"
tooltipPosition="bottom">
Expand All @@ -13,7 +14,7 @@
"></ng-container>
</div>
} @else {
<div class="cps-radio-group-content-button">
<div class="cps-radio-group-content-button" data-testid="cps-radio-button">
<ng-container
*ngTemplateOutlet="
optionRadioTemplate;
Expand All @@ -30,6 +31,7 @@
type="radio"
[id]="inputId"
[name]="groupName"
data-testid="cps-radio-button-input"
[attr.aria-label]="option.ariaLabel || null"
[disabled]="option.disabled || groupDisabled"
[value]="option.value"
Expand All @@ -41,12 +43,14 @@
<div
#contentRef
class="content"
data-testid="cps-radio-button-content"
[attr.inert]="!checked || option.disabled || groupDisabled ? '' : null">
<ng-content></ng-content>
</div>
@if (!contentRef.innerHTML.trim() && option.label) {
<label
class="cps-radio-group-content-button-label"
data-testid="cps-radio-button-label"
[for]="inputId"
[style.cursor]="option.disabled || groupDisabled ? 'default' : 'pointer'"
>{{ option.label }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<div
class="cps-radio-group"
data-testid="cps-radio-group"
role="radiogroup"
[attr.aria-label]="ariaLabel || groupLabel || null"
[attr.aria-describedby]="describedBy"
[attr.aria-disabled]="disabled || null"
[attr.aria-required]="isRequired || null"
[attr.aria-invalid]="error ? 'true' : null">
@if (groupLabel) {
<div class="cps-radio-group-label">
<div class="cps-radio-group-label" data-testid="cps-radio-group-label">
<span>{{ groupLabel }}</span>
@if (infoTooltip) {
<cps-info-circle
class="cps-radio-group-label-info-circle"
data-testid="cps-radio-group-info-circle"
size="xsmall"
[tooltipPosition]="infoTooltipPosition"
[tooltipContentClass]="infoTooltipClass"
Expand All @@ -26,6 +28,7 @@
<div
#contentRef
class="cps-radio-group-content"
data-testid="cps-radio-group-content"
[class.cps-radio-group-content-vertical]="vertical"
[class.cps-radio-group-content-horizontal]="!vertical">
Comment thread
fateeand marked this conversation as resolved.
<ng-content></ng-content>
Expand All @@ -34,10 +37,12 @@
@if (!contentRef.innerHTML.trim()) {
<div
class="cps-radio-group-content"
data-testid="cps-radio-group-options"
[class.cps-radio-group-content-vertical]="vertical"
[class.cps-radio-group-content-horizontal]="!vertical">
@for (option of options; track option) {
@for (option of options; track option; let i = $index) {
<cps-radio-button
[attr.data-testid]="'cps-radio-group-option-' + i"
[option]="option"
[groupName]="groupName"
[checked]="option.value === value"
Expand All @@ -50,14 +55,18 @@
</div>
}
@if (!error && !hideDetails) {
<div [id]="hintId" class="cps-radio-hint">
<div
[id]="hintId"
class="cps-radio-hint"
data-testid="cps-radio-group-hint">
{{ hint }}
</div>
}
@if (error && !hideDetails) {
<div
[id]="errorId"
class="cps-radio-error"
data-testid="cps-radio-group-error"
aria-live="polite"
aria-atomic="true">
{{ error }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { CpsRadioButtonComponent } from '../cps-radio-button/cps-radio-button.co
selector: 'cps-radio',
imports: [CpsRadioButtonComponent],
templateUrl: './cps-radio.component.html',
styleUrls: ['./cps-radio.component.scss']
styleUrls: ['./cps-radio.component.scss'],
host: { 'data-testid': 'cps-radio' }
})
export class CpsRadioComponent implements OnInit {
/**
Expand Down
Loading