diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts index 17eddb854555..cf7300521dab 100644 --- a/src/cdk-experimental/radio/radio.spec.ts +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -508,6 +508,14 @@ describe('CdkRadioGroup', () => { setupRadioGroup({options: []}); expect(radioButtons.length).toBe(0); }); + + describe('bad accessibility violations', () => { + it('should report when the selected radio button is disabled and skipDisabled is true', () => { + spyOn(console, 'error'); + setupRadioGroup({value: 1, skipDisabled: true, disabledOptions: [1]}); + expect(console.error).toHaveBeenCalled(); + }); + }); }); }); diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts index adb623261ef5..0fa65156c3e3 100644 --- a/src/cdk-experimental/radio/radio.ts +++ b/src/cdk-experimental/radio/radio.ts @@ -137,6 +137,15 @@ export class CdkRadioGroup { private _hasFocused = signal(false); constructor() { + afterRenderEffect(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const violations = this.pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + } + }); + afterRenderEffect(() => { if (!this._hasFocused()) { this.pattern.setDefaultState(); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index 639c292c93d4..3fc336327fce 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; @@ -36,6 +36,11 @@ export class ListSelection, V> { /** The end index to use for range selection. */ rangeEndIndex = signal(0); + /** The currently selected items. */ + selectedItems = computed(() => + this.inputs.items().filter(item => this.inputs.value().includes(item.value())), + ); + constructor(readonly inputs: ListSelectionInputs & {focusManager: ListFocus}) {} /** Selects the item at the current active index. */ diff --git a/src/cdk-experimental/ui-patterns/radio/radio-group.ts b/src/cdk-experimental/ui-patterns/radio/radio-group.ts index 79a9c4402ad6..ca525929b39d 100644 --- a/src/cdk-experimental/ui-patterns/radio/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio/radio-group.ts @@ -48,8 +48,11 @@ export class RadioGroupPattern { /** Whether the radio group is disabled. */ disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled()); + /** The currently selected radio button. */ + selectedItem = computed(() => this.selection.selectedItems()[0]); + /** Whether the radio group is readonly. */ - readonly: SignalLike; + readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly()); /** The tabindex of the radio group (if using activedescendant). */ tabindex = computed(() => this.focusManager.getListTabindex()); @@ -111,7 +114,6 @@ export class RadioGroupPattern { }); constructor(readonly inputs: RadioGroupInputs) { - this.readonly = inputs.readonly; this.orientation = inputs.orientation; this.focusManager = new ListFocus(inputs); @@ -194,6 +196,19 @@ export class RadioGroupPattern { } } + /** Validates the state of the radio group and returns a list of accessibility violations. */ + validate(): string[] { + const violations: string[] = []; + + if (this.selectedItem()?.disabled() && this.inputs.skipDisabled()) { + violations.push( + "Accessibility Violation: The selected radio button is disabled while 'skipDisabled' is true, making the selection unreachable via keyboard.", + ); + } + + return violations; + } + /** Safely performs a navigation operation and updates selection if needed. */ private _navigate(opts: SelectOptions = {}, operation: () => boolean) { const moved = operation(); diff --git a/src/cdk-experimental/ui-patterns/radio/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts index 19a3644372f6..3cf31f6bcc0b 100644 --- a/src/cdk-experimental/ui-patterns/radio/radio.spec.ts +++ b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts @@ -298,4 +298,16 @@ describe('RadioGroup Pattern', () => { expect(radioGroup.inputs.activeIndex()).toBe(0); // Defaults to first focusable }); }); + + describe('validate', () => { + it('should report a violation if the selected item is disabled and skipDisabled is true', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({ + value: signal(['Banana']), + skipDisabled: signal(true), + }); + radioButtons[1].disabled.set(true); // Disable the selected item. + const violations = radioGroup.validate(); + expect(violations.length).toBe(1); + }); + }); }); diff --git a/src/components-examples/cdk-experimental/radio/BUILD.bazel b/src/components-examples/cdk-experimental/radio/BUILD.bazel new file mode 100644 index 000000000000..f33b43cf96d0 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "radio", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/cdk-experimental/radio", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css new file mode 100644 index 000000000000..d2ed35cc00c0 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css @@ -0,0 +1,106 @@ +.example-radio-controls { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 16px; +} + +.example-radio-group { + gap: 4px; + margin: 0; + padding: 8px; + max-height: 50vh; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + list-style: none; + flex-direction: column; + overflow: scroll; + user-select: none; +} + +.example-radio-group[aria-orientation='horizontal'] { + flex-direction: row; +} + +.example-radio-group[aria-disabled='true'] { + background-color: var(--mat-sys-surface-dim); + pointer-events: none; +} + +.example-radio-button { + gap: 16px; + padding: 16px; + display: flex; + cursor: pointer; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +/* Basic visual indicator for the radio button */ +.example-radio-indicator { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--mat-sys-outline); + display: inline-block; + position: relative; +} + +.example-radio-button[aria-checked='true'] .example-radio-indicator { + border-color: var(--mat-sys-primary); +} + +.example-radio-button[aria-checked='true'] .example-radio-indicator::after { + content: ''; + display: block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--mat-sys-primary); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.example-radio-button[aria-disabled='true'][aria-checked='true'] .example-radio-indicator::after { + background-color: var(--mat-sys-outline); +} + +.example-radio-button.cdk-active, +.example-radio-button[aria-disabled='false']:hover { + outline: 1px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-container); +} + +.example-radio-button[aria-disabled='false']:focus-within { + outline: 2px solid var(--mat-sys-primary); + background: var(--mat-sys-surface-container); +} + +.example-radio-button.cdk-active[aria-disabled='true'], +.example-radio-button[aria-disabled='true']:focus-within { + outline: 2px solid var(--mat-sys-outline); +} + +.example-radio-button[aria-disabled='true'] { + cursor: default; +} + +.example-radio-button[aria-disabled='true'] span:not(.example-radio-indicator) { + opacity: 0.3; +} + +.example-radio-button[aria-disabled='true']::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-on-surface); + opacity: var(--mat-sys-focus-state-layer-opacity); +} diff --git a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.html b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.html new file mode 100644 index 000000000000..43757b1bc610 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.html @@ -0,0 +1,62 @@ +
+ Disabled + Readonly + Skip Disabled + + + Selected Flavor + + None + @for (flavor of flavors; track flavor) { + {{flavor}} + } + + + + + Disabled Radio Options + + @for (flavor of flavors; track flavor) { + {{flavor}} + } + + + + + Orientation + + Vertical + Horizontal + + + + + Focus strategy + + Roving Tabindex + Active Descendant + + +
+ + +
    + @for (flavor of flavors; track flavor) { + @let optionDisabled = disabledOptions.includes(flavor); +
  • + + {{ flavor }} +
  • + } +
+ diff --git a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.ts b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.ts new file mode 100644 index 000000000000..6d0cc086b898 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.ts @@ -0,0 +1,39 @@ +import {Component} from '@angular/core'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; + +/** @title Basic CDK Radio Group */ +@Component({ + selector: 'cdk-radio-example', + exportAs: 'cdkRadioExample', + templateUrl: 'cdk-radio-example.html', + styleUrl: 'cdk-radio-example.css', + standalone: true, + imports: [ + CdkRadioGroup, + CdkRadioButton, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + FormsModule, + ReactiveFormsModule, + ], +}) +export class CdkRadioExample { + orientation: 'vertical' | 'horizontal' = 'vertical'; + disabled = new FormControl(false, {nonNullable: true}); + + flavors = ['Vanilla', 'Chocolate', 'Strawberry', 'Mint Chip', 'Cookies & Cream', 'Rocky Road']; + + // New controls + readonly = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + focusMode: 'roving' | 'activedescendant' = 'roving'; + selectedValue: string | null = 'Vanilla'; // To control/reflect the radio group's value + + // Control for which radio options are individually disabled + disabledOptions: string[] = ['Chocolate']; +} diff --git a/src/components-examples/cdk-experimental/radio/index.ts b/src/components-examples/cdk-experimental/radio/index.ts new file mode 100644 index 000000000000..722738a66748 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/index.ts @@ -0,0 +1 @@ +export {CdkRadioExample} from './cdk-radio/cdk-radio-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 199684c45d76..4d18df841808 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -1,8 +1,8 @@ -load("//tools:defaults.bzl", "http_server", "ng_project", "sass_binary") -load("@npm//:defs.bzl", "npm_link_all_packages") load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") load("//src/components-examples:config.bzl", "ALL_EXAMPLES") +load("//tools:defaults.bzl", "http_server", "ng_project", "sass_binary") package(default_visibility = ["//visibility:public"]) @@ -36,6 +36,7 @@ ng_project( "//src/dev-app/cdk-experimental-accordion", "//src/dev-app/cdk-experimental-combobox", "//src/dev-app/cdk-experimental-listbox", + "//src/dev-app/cdk-experimental-radio", "//src/dev-app/cdk-experimental-tabs", "//src/dev-app/cdk-listbox", "//src/dev-app/cdk-menu", diff --git a/src/dev-app/cdk-experimental-radio/BUILD.bazel b/src/dev-app/cdk-experimental-radio/BUILD.bazel new file mode 100644 index 000000000000..bbd2644b2cf5 --- /dev/null +++ b/src/dev-app/cdk-experimental-radio/BUILD.bazel @@ -0,0 +1,21 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-radio", + srcs = glob(["**/*.ts"]), + assets = ["cdk-radio-demo.html"], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/cdk-experimental/radio", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.ts", + "**/*.html", + ]), +) diff --git a/src/dev-app/cdk-experimental-radio/cdk-radio-demo.html b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.html new file mode 100644 index 000000000000..ee2ca03aa2b4 --- /dev/null +++ b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.html @@ -0,0 +1,4 @@ +
+

Radio using UI Patterns

+ +
diff --git a/src/dev-app/cdk-experimental-radio/cdk-radio-demo.ts b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.ts new file mode 100644 index 000000000000..e170b556d703 --- /dev/null +++ b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {CdkRadioExample} from '@angular/components-examples/cdk-experimental/radio'; + +@Component({ + templateUrl: 'cdk-radio-demo.html', + imports: [CdkRadioExample], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalRadioDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 21bdd2d72b75..e769dbfa8ecd 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -60,6 +60,7 @@ export class DevAppLayout { {name: 'Examples', route: '/examples'}, {name: 'CDK Dialog', route: '/cdk-dialog'}, {name: 'CDK Experimental Combobox', route: '/cdk-experimental-combobox'}, + {name: 'CDK Experimental Radio', route: '/cdk-experimental-radio'}, {name: 'CDK Experimental Listbox', route: '/cdk-experimental-listbox'}, {name: 'CDK Experimental Tabs', route: '/cdk-experimental-tabs'}, {name: 'CDK Experimental Accordion', route: '/cdk-experimental-accordion'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index c311f4e4396c..92ec9f59e71b 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -50,6 +50,11 @@ export const DEV_APP_ROUTES: Routes = [ loadComponent: () => import('./cdk-experimental-listbox/cdk-listbox-demo').then(m => m.CdkExperimentalListboxDemo), }, + { + path: 'cdk-experimental-radio', + loadComponent: () => + import('./cdk-experimental-radio/cdk-radio-demo').then(m => m.CdkExperimentalRadioDemo), + }, { path: 'cdk-experimental-tabs', loadComponent: () =>