diff --git a/playwright/cps-ui-kit/components/cps-radio-group.spec.ts b/playwright/cps-ui-kit/components/cps-radio-group.spec.ts new file mode 100644 index 000000000..ef01fa5ad --- /dev/null +++ b/playwright/cps-ui-kit/components/cps-radio-group.spec.ts @@ -0,0 +1,128 @@ +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 focus/blur forwarding from custom-content cps-radio', () => { + test('focusing then blurring a custom-content radio without selecting shows a real required error', async ({ + page + }) => { + const group = radioGroup(page, 'custom-content-radio-group'); + const errorEl = group.getByTestId('cps-radio-group-error'); + + const simpleOption = group.getByRole('radio', { name: 'Simple option' }); + await simpleOption.focus(); + await page.evaluate(() => + (document.activeElement as HTMLElement)?.blur() + ); + + await expect(errorEl).toBeVisible(); + await expect(errorEl).toHaveText('Field is required'); + await expect(group).toHaveAttribute('aria-invalid', 'true'); + }); + }); + + 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); + }); + }); +}); diff --git a/projects/composition/src/app/pages/radio-page/radio-page.component.html b/projects/composition/src/app/pages/radio-page/radio-page.component.html index 70fde3e68..d4279077d 100644 --- a/projects/composition/src/app/pages/radio-page/radio-page.component.html +++ b/projects/composition/src/app/pages/radio-page/radio-page.component.html @@ -6,6 +6,7 @@ [tsCode]="examples.requiredRadioGroup.ts">
@@ -13,6 +14,22 @@ + +
+ + +
+
+ + + + + + - - -
- On the - - - of every - - - month(s) at - - - : - - - -
-
- -
+
+ + +
+ On the + + + of every + + + month(s) at + + + : + + + +
+
+ +
+
diff --git a/projects/composition/src/app/pages/radio-page/radio-page.component.ts b/projects/composition/src/app/pages/radio-page/radio-page.component.ts index f70572799..556680524 100644 --- a/projects/composition/src/app/pages/radio-page/radio-page.component.ts +++ b/projects/composition/src/app/pages/radio-page/radio-page.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { AbstractControl, FormsModule, @@ -88,19 +88,18 @@ 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], + requiredCustomContentRadio: ['', Validators.required] }); } diff --git a/projects/composition/src/app/pages/radio-page/radio-page.examples.ts b/projects/composition/src/app/pages/radio-page/radio-page.examples.ts index 85bc6fde3..92632496d 100644 --- a/projects/composition/src/app/pages/radio-page/radio-page.examples.ts +++ b/projects/composition/src/app/pages/radio-page/radio-page.examples.ts @@ -35,6 +35,48 @@ ngOnInit() { }); } +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: ` +
+ + +
`, + 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; @@ -105,6 +147,16 @@ partiallyDisabledOptions: CpsRadioOption[] = [ ];` }, + hintRadioGroup: { + html: ` + +`, + ts: radioOptionsTs + }, + twoWayBindingRadioGroup: { html: `
@@ -123,57 +175,63 @@ syncVal = 'first';` customContentRadioGroup: { html: ` - - -
- On the - - - of every - - - month(s) at - - - : - - - -
-
- -
`, +
+ + +
+ On the + + + of every + + + month(s) at + + + : + + + +
+
+ +
+
`, ts: ` +private readonly _formBuilder = inject(UntypedFormBuilder); + +form!: UntypedFormGroup; + dayOptions = [ { name: '1st day', data: { code: '1' } }, { name: '2nd day', data: { code: '2' } }, @@ -193,6 +251,12 @@ hourOptions = [...Array(24).keys()].map((n) => ({ minuteOptions = [...Array(60).keys()].map((n) => ({ name: n, data: { code: n } -}));` +})); + +ngOnInit() { + this.form = this._formBuilder.group({ + requiredCustomContentRadio: ['', Validators.required] + }); +}` } }; diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.html b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.html index 451efc2f4..abb5bc63a 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.html @@ -1,6 +1,7 @@ @if (option.tooltip) {
@@ -13,7 +14,7 @@ ">
} @else { -
+
@if (!contentRef.innerHTML.trim() && option.label) {