From 5f0260f5ca0a8dadbef6294406fe7a41efa8cb04 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 22 Apr 2026 11:13:39 +0200 Subject: [PATCH 1/2] Fix a11y issues in radio component --- playwright/cps-accessibility.spec.ts | 4 +- .../src/app/api-data/cps-notification.json | 2 +- .../src/app/api-data/cps-radio-group.json | 20 ++++++- .../src/app/api-data/cps-table.json | 12 ++-- .../src/app/api-data/cps-tree-table.json | 12 ++-- .../app/api-data/cron-validation.service.json | 2 +- .../radio-page/radio-page.component.html | 12 +++- .../radio-page/radio-page.component.scss | 8 +-- .../cps-autocomplete.component.html | 5 +- .../cps-autocomplete.component.ts | 14 ++++- .../cps-button-toggle.component.ts | 9 ++- .../cps-button/cps-button.component.spec.ts | 4 ++ .../cps-radio-button.component.html | 37 ++++++------ .../cps-radio-button.component.scss | 34 ++++++++--- .../cps-radio-button.component.ts | 32 +++++++---- .../cps-radio-group.component.html | 25 +++++---- .../cps-radio-group.component.scss | 10 ++-- .../cps-radio-group.component.spec.ts | 56 +++++++++++++++++++ .../cps-radio-group.component.ts | 38 ++++++++++++- .../cps-radio/cps-radio.component.html | 1 + .../cps-scheduler.component.html | 43 ++++++++++++-- .../lib/utils/internal/accessibility-utils.ts | 3 +- 22 files changed, 290 insertions(+), 93 deletions(-) diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 403f675b..82f78fb6 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -27,7 +27,7 @@ const components: ComponentEntry[] = [ selector: 'cps-button-toggle' }, { route: '/checkbox', name: 'Checkbox', selector: 'cps-checkbox' }, - { route: '/chip', name: 'Chip', selector: 'cps-chip' } + { route: '/chip', name: 'Chip', selector: 'cps-chip' }, // { // route: '/datepicker', // name: 'Datepicker', @@ -90,7 +90,7 @@ const components: ComponentEntry[] = [ // name: 'Progress linear', // selector: 'cps-progress-linear' // }, - // { route: '/radio-group', name: 'Radio', selector: 'cps-radio-group' }, + { route: '/radio-group', name: 'Radio', selector: 'cps-radio-group' } // { route: '/scheduler', name: 'Scheduler', selector: 'cps-scheduler' }, // { // route: '/select', diff --git a/projects/composition/src/app/api-data/cps-notification.json b/projects/composition/src/app/api-data/cps-notification.json index 52ffc175..93baf712 100644 --- a/projects/composition/src/app/api-data/cps-notification.json +++ b/projects/composition/src/app/api-data/cps-notification.json @@ -127,7 +127,7 @@ "optional": true, "readonly": false, "type": "number", - "description": "The duration (in milliseconds) that the notification will be displayed before automatically closing.\r\nValue 0 means that the notification is persistent and will not be automatically closed." + "description": "The duration (in milliseconds) that the notification will be displayed before automatically closing.\nValue 0 means that the notification is persistent and will not be automatically closed." }, { "name": "allowDuplicates", diff --git a/projects/composition/src/app/api-data/cps-radio-group.json b/projects/composition/src/app/api-data/cps-radio-group.json index ebf62f31..4be344d7 100644 --- a/projects/composition/src/app/api-data/cps-radio-group.json +++ b/projects/composition/src/app/api-data/cps-radio-group.json @@ -5,9 +5,17 @@ "props": { "description": "Defines the input properties of the component.", "values": [ + { + "name": "groupName", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Name attribute for the radio input, used to group buttons within the same radio group." + }, { "name": "option", - "optional": true, + "optional": false, "readonly": false, "type": "CpsRadioOption", "description": "An option." @@ -87,6 +95,14 @@ "default": "", "description": "Label of the radio group." }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Aria label for the radio group, used for accessibility, it takes precedence over groupLabel." + }, { "name": "vertical", "optional": false, @@ -225,7 +241,7 @@ "values": [ { "name": "CpsRadioOption", - "value": "{\n \"value\": \"any\",\n \"label\": \"string\",\n \"disabled\": \"boolean\",\n \"tooltip\": \"string\"\n}", + "value": "{\n \"value\": \"any\",\n \"label\": \"string\",\n \"ariaLabel\": \"string\",\n \"disabled\": \"boolean\",\n \"tooltip\": \"string\"\n}", "description": "CpsRadioOption is used to define the options of the CpsRadioGroupComponent." } ] diff --git a/projects/composition/src/app/api-data/cps-table.json b/projects/composition/src/app/api-data/cps-table.json index 3b7375d8..2f546faf 100644 --- a/projects/composition/src/app/api-data/cps-table.json +++ b/projects/composition/src/app/api-data/cps-table.json @@ -107,7 +107,7 @@ "readonly": false, "type": "boolean", "default": "true", - "description": "Determines whether the 'Remove' button should be displayed in the row menu.\r\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." + "description": "Determines whether the 'Remove' button should be displayed in the row menu.\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." }, { "name": "showRowEditButton", @@ -115,14 +115,14 @@ "readonly": false, "type": "boolean", "default": "true", - "description": "Determines whether the 'Edit' button should be displayed in the row menu.\r\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." + "description": "Determines whether the 'Edit' button should be displayed in the row menu.\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." }, { "name": "rowMenuItems", "optional": true, "readonly": false, "type": "CpsMenuItem[]", - "description": "Custom items to be displayed in the row menu.\r\nNote: This setting only takes effect if 'showRowMenu' is true." + "description": "Custom items to be displayed in the row menu.\nNote: This setting only takes effect if 'showRowMenu' is true." }, { "name": "reorderableRows", @@ -178,7 +178,7 @@ "readonly": false, "type": "boolean", "default": "true", - "description": "If true, automatically detects filter type based on values, otherwise sets 'text' filter type for all columns.\r\nNote: This setting only takes effect if 'filterableByColumns' is true." + "description": "If true, automatically detects filter type based on values, otherwise sets 'text' filter type for all columns.\nNote: This setting only takes effect if 'filterableByColumns' is true." }, { "name": "sortMode", @@ -570,7 +570,7 @@ "readonly": false, "type": "boolean", "default": "false", - "description": "Determines whether columns are resizable.\r\nIn case of using a custom template for columns, it is also needed to add cpsTColResizable directive to th elements." + "description": "Determines whether columns are resizable.\nIn case of using a custom template for columns, it is also needed to add cpsTColResizable directive to th elements." }, { "name": "columnResizeMode", @@ -578,7 +578,7 @@ "readonly": false, "type": "\"expand\" | \"fit\"", "default": "fit", - "description": "Determines how the columns are resized. It can be 'fit' (total width of the table stays the same) or 'expand' (total width of the table changes when resizing columns).\r\nNote: This setting only takes effect if 'resizableColumns' is true." + "description": "Determines how the columns are resized. It can be 'fit' (total width of the table stays the same) or 'expand' (total width of the table changes when resizing columns).\nNote: This setting only takes effect if 'resizableColumns' is true." } ] }, diff --git a/projects/composition/src/app/api-data/cps-tree-table.json b/projects/composition/src/app/api-data/cps-tree-table.json index 145823c9..04fbe608 100644 --- a/projects/composition/src/app/api-data/cps-tree-table.json +++ b/projects/composition/src/app/api-data/cps-tree-table.json @@ -123,7 +123,7 @@ "readonly": false, "type": "boolean", "default": "true", - "description": "Determines whether the 'Remove' button should be displayed in the row menu.\r\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." + "description": "Determines whether the 'Remove' button should be displayed in the row menu.\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." }, { "name": "showRowEditButton", @@ -131,14 +131,14 @@ "readonly": false, "type": "boolean", "default": "true", - "description": "Determines whether the 'Edit' button should be displayed in the row menu.\r\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." + "description": "Determines whether the 'Edit' button should be displayed in the row menu.\nNote: This setting only takes effect if 'showRowMenu' is true and 'rowMenuItems' is not set." }, { "name": "rowMenuItems", "optional": true, "readonly": false, "type": "CpsMenuItem[]", - "description": "Custom items to be displayed in the row menu.\r\nNote: This setting only takes effect if 'showRowMenu' is true." + "description": "Custom items to be displayed in the row menu.\nNote: This setting only takes effect if 'showRowMenu' is true." }, { "name": "loading", @@ -530,7 +530,7 @@ "readonly": false, "type": "boolean", "default": "true", - "description": "If true, automatically detects filter type based on values, otherwise sets 'text' filter type for all columns.\r\nNote: This setting only takes effect if 'filterableByColumns' is true." + "description": "If true, automatically detects filter type based on values, otherwise sets 'text' filter type for all columns.\nNote: This setting only takes effect if 'filterableByColumns' is true." }, { "name": "showExportBtn", @@ -586,7 +586,7 @@ "readonly": false, "type": "boolean", "default": "false", - "description": "Determines whether columns are resizable.\r\nIn case of using a custom template for columns, it is also needed to add cpsTTColResizable directive to th elements." + "description": "Determines whether columns are resizable.\nIn case of using a custom template for columns, it is also needed to add cpsTTColResizable directive to th elements." }, { "name": "columnResizeMode", @@ -594,7 +594,7 @@ "readonly": false, "type": "\"expand\" | \"fit\"", "default": "fit", - "description": "Determines how the columns are resized. It can be 'fit' (total width of the table stays the same) or 'expand' (total width of the table changes when resizing columns).\r\nNote: This setting only takes effect if 'resizableColumns' is true." + "description": "Determines how the columns are resized. It can be 'fit' (total width of the table stays the same) or 'expand' (total width of the table changes when resizing columns).\nNote: This setting only takes effect if 'resizableColumns' is true." } ] }, diff --git a/projects/composition/src/app/api-data/cron-validation.service.json b/projects/composition/src/app/api-data/cron-validation.service.json index 77d68f60..28b4e9af 100644 --- a/projects/composition/src/app/api-data/cron-validation.service.json +++ b/projects/composition/src/app/api-data/cron-validation.service.json @@ -1,7 +1,7 @@ { "components": {}, "name": "CronValidationService", - "description": "Service for validating 6-field cron expressions with extended features.\r\n\r\nThis service handles cron validation logic for extended cron expression formats\r\nthat support additional features beyond standard Unix cron for more flexible\r\nscheduling capabilities.\r\n\r\nFormat: minutes hours day-of-month month day-of-week year\r\n\r\nKey Features:\r\n- Wildcards: asterisk (any value), question mark (any value for day fields)\r\n- Ranges: 1-5, MON-FRI, JAN-MAR\r\n- Steps: asterisk/15, 5/10, 1-5/2\r\n- Lists: 1,3,5, MON,WED,FRI\r\n- Special chars: L (last), W (weekday), hash (nth occurrence)", + "description": "Service for validating 6-field cron expressions with extended features.\n\nThis service handles cron validation logic for extended cron expression formats\nthat support additional features beyond standard Unix cron for more flexible\nscheduling capabilities.\n\nFormat: minutes hours day-of-month month day-of-week year\n\nKey Features:\n- Wildcards: asterisk (any value), question mark (any value for day fields)\n- Ranges: 1-5, MON-FRI, JAN-MAR\n- Steps: asterisk/15, 5/10, 1-5/2\n- Lists: 1,3,5, MON,WED,FRI\n- Special chars: L (last), W (weekday), hash (nth occurrence)", "methods": { "description": "Methods used in service.", "values": [ 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 ef660130..9f118feb 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 @@ -14,6 +14,12 @@ infoTooltip="Provide any information here" value="second"> + + - +
On the } @if ((error || externalError) && !hideDetails) { -
+ } @@ -319,14 +319,15 @@ type="text" class="cps-autocomplete-box-input" spellcheck="false" - [attr.aria-invalid]="error || externalError ? true : null" [disabled]="disabled" role="combobox" aria-autocomplete="list" aria-haspopup="listbox" + [attr.aria-invalid]="error || externalError ? 'true' : null" [attr.aria-controls]="optionsListId" [attr.aria-activedescendant]="activeDescendantId" [attr.aria-expanded]="isOpened" + [attr.aria-required]="isRequired || null" [ngClass]="inputClass" [ngStyle]="inputStyle" [placeholder]=" diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts index 011de8fe..a86bbdc1 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts @@ -16,7 +16,12 @@ import { SimpleChanges, ViewChild } from '@angular/core'; -import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms'; +import { + ControlValueAccessor, + FormsModule, + NgControl, + Validators +} from '@angular/forms'; import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs'; import { convertSize } from '../../utils/internal/size-utils'; import { @@ -852,11 +857,14 @@ export class CpsAutocompleteComponent this.recalcVirtualListHeight(); } + get isRequired(): boolean { + return this._control?.control?.hasValidator(Validators.required) ?? false; + } + get computedLabel(): string | null { return getComputedLabel({ label: this.ariaLabel || this.label, - error: this.error, - externalError: this.externalError, + error: this.error || this.externalError, hideDetails: this.hideDetails }); } diff --git a/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts b/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts index 5091fd21..b6541166 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts @@ -1,5 +1,6 @@ import { CommonModule, DOCUMENT } from '@angular/common'; import { + ChangeDetectorRef, Component, EventEmitter, Inject, @@ -161,7 +162,8 @@ export class CpsButtonToggleComponent constructor( @Self() @Optional() private _control: NgControl, @Inject(DOCUMENT) private document: Document, - private renderer: Renderer2 + private renderer: Renderer2, + private cdr: ChangeDetectorRef ) { if (this._control) { this._control.valueAccessor = this; @@ -176,7 +178,10 @@ export class CpsButtonToggleComponent getComputedStyle(this.document.documentElement).fontSize || '16' ); if (this.document?.fonts?.ready) { - this.document.fonts.ready.then(() => this._setEqualWidths()); + this.document.fonts.ready.then(() => { + this._setEqualWidths(); + this.cdr.markForCheck(); + }); } else { this._setEqualWidths(); } diff --git a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts index a5492e64..f2fea4fa 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.spec.ts @@ -276,11 +276,15 @@ describe('CpsButtonComponent', () => { }); it('should not set aria-label when neither label nor ariaLabel is provided', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); fixture.componentRef.setInput('label', ''); fixture.componentRef.setInput('ariaLabel', ''); fixture.detectChanges(); const button = fixture.nativeElement.querySelector('button'); expect(button.getAttribute('aria-label')).toBeNull(); + consoleSpy.mockRestore(); }); it('should error when neither label nor ariaLabel is provided', () => { 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 ed858325..79bbdf49 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,7 +1,7 @@ -@if (option?.tooltip) { +@if (option.tooltip) {
-} -@if (!option?.tooltip) { +} @else {
- -
+ + + +
- @if (!contentRef.innerHTML.trim()) { + @if (!contentRef.innerHTML.trim() && option.label) { } diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.scss b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.scss index 632c6372..a1e64652 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.scss @@ -1,8 +1,10 @@ +@use '../../../../../styles/mixins' as *; + $color: var(--cps-color-calm); -$border-color: var(--cps-color-text-mild); -$disabled-color: var(--cps-color-text-lightest); +$border-color: var(--cps-color-text-medium); +$disabled-color: var(--cps-color-text-light); $label-color: var(--cps-color-text-dark); -$disabled-label-color: var(--cps-color-text-light); +$disabled-label-color: var(--cps-color-text-mild); :host { .cps-radio-group-content-button { @@ -33,6 +35,8 @@ $disabled-label-color: var(--cps-color-text-light); display: grid; place-content: center; + position: absolute; + inset: 0; &:hover, &:checked { @@ -59,13 +63,29 @@ $disabled-label-color: var(--cps-color-text-light); box-shadow: inset 1rem 1rem $disabled-color; } - &:disabled + .cps-radio-group-content-button-label { - color: $disabled-label-color; - } - &:checked::before { transform: scale(0.5); } + + &:focus-visible { + outline: none; + } + } + + .cps-radio-input-wrapper { + display: block; + position: relative; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + + &:has(input:focus-visible) { + @include focus-ring(0.1875rem, 0.25rem, 50%); + } + } + + &:has(input:disabled) .cps-radio-group-content-button-label { + color: $disabled-label-color; } .cps-radio-group-content-button-label { diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts index be6b8859..7a3d95b6 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts @@ -1,10 +1,14 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { + Component, + EventEmitter, + Input, + OnChanges, + Output +} from '@angular/core'; import { CpsRadioOption } from '../cps-radio-group.component'; import { CommonModule } from '@angular/common'; import { CpsTooltipDirective } from '../../../directives/cps-tooltip/cps-tooltip.directive'; -let nextUniqueId = 0; - /** * CpsRadioButtonComponent is an internal radio button component. * @group Components @@ -15,14 +19,18 @@ let nextUniqueId = 0; templateUrl: './cps-radio-button.component.html', styleUrls: ['./cps-radio-button.component.scss'] }) -export class CpsRadioButtonComponent { - private _uniqueId = `cps-radio-button-${++nextUniqueId}`; +export class CpsRadioButtonComponent implements OnChanges { + /** + * Name attribute for the radio input, used to group buttons within the same radio group. + * @group Props + */ + @Input() groupName = ''; /** * An option. * @group Props */ - @Input() option?: CpsRadioOption; + @Input() option!: CpsRadioOption; /** * Determines whether the radio button is checked. @@ -57,14 +65,18 @@ export class CpsRadioButtonComponent { */ @Output() focused = new EventEmitter(); - get inputId(): string { - return `${this._uniqueId}-input`; + ngOnChanges(): void { + if (!this.option.label?.trim() && !this.option.ariaLabel?.trim()) { + console.error( + 'CpsRadioButtonComponent: unlabeled radio button component must have an ariaLabel for accessibility.' + ); + } } updateValue(event: Event): void { event.preventDefault(); - if (this.option?.disabled) return; - this.updateValueEvent.emit(this.option?.value); + if (this.option.disabled) return; + this.updateValueEvent.emit(this.option.value); } onBlur() { diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.html b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.html index f9c1eae7..aa83dc86 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.html @@ -1,4 +1,10 @@ -
+
@if (groupLabel) {
{{ groupLabel }} @@ -19,25 +25,20 @@
+ [class.cps-radio-group-content-vertical]="vertical" + [class.cps-radio-group-content-horizontal]="!vertical">
@if (!contentRef.innerHTML.trim()) {
+ [class.cps-radio-group-content-vertical]="vertical" + [class.cps-radio-group-content-horizontal]="!vertical"> @for (option of options; track option) { } @if (error && !hideDetails) { -
+ } diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.scss b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.scss index 170a772c..cd7a5e8e 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.scss @@ -19,11 +19,11 @@ $color-error: var(--cps-state-error); cursor: default; .cps-radio-group-label-info-circle { - margin-left: 8px; + margin-left: 0.5rem; ::ng-deep cps-icon { i { - width: 14px; - height: 14px; + width: 0.875rem; + height: 0.875rem; } } } @@ -49,7 +49,7 @@ $color-error: var(--cps-state-error); } .cps-radio-hint { - margin-top: 4px; + margin-top: 0.25rem; color: $radio-hint-color; font-size: 0.75rem; min-height: 1.125rem; @@ -58,7 +58,7 @@ $color-error: var(--cps-state-error); } .cps-radio-error { - margin-top: 4px; + margin-top: 0.25rem; color: $color-error; font-weight: bold; font-size: 0.75rem; diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.spec.ts index 6d325c57..1c885951 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.spec.ts @@ -18,6 +18,7 @@ describe('CpsRadioGroupComponent', () => { { label: 'Option 2', value: 'opt2' }, { label: 'Option 3', value: 'opt3' } ]); + fixture.componentRef.setInput('ariaLabel', 'Radio group'); fixture.detectChanges(); }); @@ -140,4 +141,59 @@ describe('CpsRadioGroupComponent', () => { component.ngOnDestroy(); expect(component).toBeTruthy(); }); + + describe('aria-label', () => { + it('should apply ariaLabel as the aria-label attribute on the radiogroup element', () => { + fixture.componentRef.setInput('ariaLabel', 'My radio group'); + fixture.detectChanges(); + const radiogroup = fixture.nativeElement.querySelector( + '[role="radiogroup"]' + ); + expect(radiogroup.getAttribute('aria-label')).toBe('My radio group.'); + }); + + it('should fall back to groupLabel for aria-label when ariaLabel is not set', () => { + fixture.componentRef.setInput('ariaLabel', ''); + fixture.componentRef.setInput('groupLabel', 'Choose an option'); + fixture.detectChanges(); + const radiogroup = fixture.nativeElement.querySelector( + '[role="radiogroup"]' + ); + expect(radiogroup.getAttribute('aria-label')).toBe('Choose an option.'); + }); + + it('should prefer ariaLabel over groupLabel', () => { + fixture.componentRef.setInput('ariaLabel', 'Explicit label'); + fixture.componentRef.setInput('groupLabel', 'Visual label'); + fixture.detectChanges(); + const radiogroup = fixture.nativeElement.querySelector( + '[role="radiogroup"]' + ); + expect(radiogroup.getAttribute('aria-label')).toBe('Explicit label.'); + }); + + it('should log an error when neither ariaLabel nor groupLabel is provided', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + fixture.componentRef.setInput('ariaLabel', ''); + fixture.componentRef.setInput('groupLabel', ''); + fixture.detectChanges(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('ariaLabel for accessibility') + ); + consoleSpy.mockRestore(); + }); + + it('should not log an error when ariaLabel is provided', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + fixture.componentRef.setInput('ariaLabel', 'Accessible label'); + fixture.componentRef.setInput('groupLabel', ''); + fixture.detectChanges(); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts index 6949c65b..33e0f477 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts @@ -5,17 +5,22 @@ import { EventEmitter, InjectionToken, Input, + OnChanges, OnDestroy, OnInit, Optional, Output, Self } from '@angular/core'; -import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; import { CpsRadioButtonComponent } from './cps-radio-button/cps-radio-button.component'; import { Subscription } from 'rxjs'; +import { + generateUniqueId, + getComputedLabel +} from '../../utils/internal/accessibility-utils'; /** * CpsRadioOption is used to define the options of the CpsRadioGroupComponent. @@ -24,6 +29,7 @@ import { Subscription } from 'rxjs'; export type CpsRadioOption = { value: any; label?: string; + ariaLabel?: string; disabled?: boolean; tooltip?: string; }; @@ -49,7 +55,7 @@ export const CPS_RADIO_GROUP = new InjectionToken( ] }) export class CpsRadioGroupComponent - implements ControlValueAccessor, OnInit, OnDestroy + implements ControlValueAccessor, OnInit, OnChanges, OnDestroy { /** * An array of options. @@ -63,6 +69,12 @@ export class CpsRadioGroupComponent */ @Input() groupLabel = ''; + /** + * Aria label for the radio group, used for accessibility, it takes precedence over groupLabel. + * @group Props + */ + @Input() ariaLabel = ''; + /** * Determines whether the radio group should be vertical. * @group Props @@ -151,6 +163,8 @@ export class CpsRadioGroupComponent */ @Output() focused = new EventEmitter(); + readonly groupName = generateUniqueId('cps-radio-group'); + private _statusChangesSubscription?: Subscription; private _value: any = undefined; @@ -170,6 +184,14 @@ export class CpsRadioGroupComponent ); } + ngOnChanges(): void { + if (!this.groupLabel?.trim() && !this.ariaLabel?.trim()) { + console.error( + 'CpsRadioGroupComponent: unlabeled radio group component must have an ariaLabel for accessibility.' + ); + } + } + ngOnDestroy() { this._statusChangesSubscription?.unsubscribe(); } @@ -215,6 +237,18 @@ export class CpsRadioGroupComponent this.focused.emit(); } + get isRequired(): boolean { + return this._control?.control?.hasValidator(Validators.required) ?? false; + } + + get computedLabel(): string | null { + return getComputedLabel({ + label: this.ariaLabel || this.groupLabel, + error: this.error, + hideDetails: this.hideDetails + }); + } + private _checkErrors() { if (!this._control) return; const errors = this._control?.errors; diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio/cps-radio.component.html b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio/cps-radio.component.html index 9bdb9bd6..7ba2c785 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio/cps-radio.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio/cps-radio.component.html @@ -1,5 +1,6 @@ diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html index 7f1c4f49..8927e33f 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html @@ -72,12 +72,17 @@ @case ('Daily') {
+ [option]="{ + ariaLabel: 'Repeat every working day at a specific time', + value: 'everyWeekDay', + disabled: disabled + }">
{ export const getComputedLabel = (context: { label?: string; error?: string; - externalError?: string; hideDetails?: boolean; }): string | null => { const parts: string[] = []; @@ -19,7 +18,7 @@ export const getComputedLabel = (context: { } // Add error if exists and not hidden - const errorText = context.error || context.externalError; + const errorText = context.error; if (errorText && !context.hideDetails) { const cleanedError = errorText.trim().replace(/\.*$/, ''); parts.push(`Error: ${cleanedError}.`); From d436c320a831e8756630dcef3fb05267ba860a3c Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 22 Apr 2026 14:16:19 +0200 Subject: [PATCH 2/2] address copilot feedback --- .../cps-radio-button/cps-radio-button.component.html | 9 +++++++-- .../cps-radio-button/cps-radio-button.component.ts | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) 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 79bbdf49..451efc2f 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 @@ -28,8 +28,9 @@ -
+
@if (!contentRef.innerHTML.trim() && option.label) { diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts index 7a3d95b6..eaa647ca 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts @@ -8,6 +8,7 @@ import { import { CpsRadioOption } from '../cps-radio-group.component'; import { CommonModule } from '@angular/common'; import { CpsTooltipDirective } from '../../../directives/cps-tooltip/cps-tooltip.directive'; +import { generateUniqueId } from '../../../utils/internal/accessibility-utils'; /** * CpsRadioButtonComponent is an internal radio button component. @@ -65,6 +66,8 @@ export class CpsRadioButtonComponent implements OnChanges { */ @Output() focused = new EventEmitter(); + readonly inputId = generateUniqueId('cps-radio-button-input'); + ngOnChanges(): void { if (!this.option.label?.trim() && !this.option.ariaLabel?.trim()) { console.error(