diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 56b1531b30e9..e67662f70f6a 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -13,6 +13,7 @@ export const commitMessage: CommitMessageConfig = { 'cdk-experimental/combobox', 'cdk-experimental/listbox', 'cdk-experimental/popover-edit', + 'cdk-experimental/radio', 'cdk-experimental/scrolling', 'cdk-experimental/selection', 'cdk-experimental/table-scroll-container', diff --git a/package.json b/package.json index 9604187800a4..5bdf1eea78d3 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "@types/shelljs": "^0.8.11", "@types/yargs": "^17.0.8", "autoprefixer": "^10.4.2", + "axe-core": "^4.10.3", "chalk": "^4.1.0", "dgeni": "^0.4.14", "dgeni-packages": "^0.29.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0aa1dd27b1af..c28246aecc25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: autoprefixer: specifier: ^10.4.2 version: 10.4.21(postcss@8.5.3) + axe-core: + specifier: ^4.10.3 + version: 4.10.3 chalk: specifier: ^4.1.0 version: 4.1.2 @@ -3440,6 +3443,10 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axe-core@4.10.3: + resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} + engines: {node: '>=4'} + axe-core@4.7.2: resolution: {integrity: sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g==} engines: {node: '>=4'} @@ -12265,6 +12272,8 @@ snapshots: aws4@1.13.2: {} + axe-core@4.10.3: {} + axe-core@4.7.2: {} b4a@1.6.7: {} diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index 89dd51af4665..9e3cac5be0eb 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -5,6 +5,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [ "deferred-content", "listbox", "popover-edit", + "radio", "scrolling", "selection", "tabs", diff --git a/src/cdk-experimental/radio/BUILD.bazel b/src/cdk-experimental/radio/BUILD.bazel new file mode 100644 index 000000000000..3838af15170b --- /dev/null +++ b/src/cdk-experimental/radio/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "radio", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":radio", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/axe-core", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/radio/index.ts b/src/cdk-experimental/radio/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/radio/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/radio/public-api.ts b/src/cdk-experimental/radio/public-api.ts new file mode 100644 index 000000000000..0fa6cc894d73 --- /dev/null +++ b/src/cdk-experimental/radio/public-api.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export {CdkRadioGroup, CdkRadioButton} from './radio'; diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts new file mode 100644 index 000000000000..249ca625575d --- /dev/null +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -0,0 +1,637 @@ +import {Component, DebugElement, signal} from '@angular/core'; +import {CdkRadioButton, CdkRadioGroup} from './radio'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule, Direction} from '@angular/cdk/bidi'; +import {provideFakeDirectionality} from '@angular/cdk/testing/private'; +import axe from 'axe-core'; + +// Basic ANSI color functions because chalk has issues with unit tests. +const colors = { + red: (text: string) => `\x1b[31m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, + blue: (text: string) => `\x1b[34m${text}\x1b[0m`, + magenta: (text: string) => `\x1b[35m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, + gray: (text: string) => `\x1b[90m${text}\x1b[0m`, + underline: (text: string) => `\x1b[4m${text}\x1b[0m`, + default: (text: string) => `\x1b[0m${text}\x1b[0m`, +}; + +// TODO: Move this to a separate folder/file so it can be reused across components. +async function runAccessibilityChecks(root: HTMLElement): Promise { + const results = await axe.run(root); + + if (!results.violations.length) { + return; + } + + const reportLines: string[] = []; + const append = (text: string) => reportLines.push(colors.default(text)); + append(colors.red(`Found ${results.violations.length} accessibility violation(s):`)); + + results.violations.forEach((violation, index) => { + append(''); + append(colors.red(`Violation ${index + 1}: ${violation.id}\n`)); + + let impactText = violation.impact || 'unknown'; + switch (violation.impact) { + case 'critical': + impactText = colors.red(impactText); + break; + case 'serious': + impactText = colors.yellow(impactText); + break; + case 'moderate': + impactText = colors.blue(impactText); + break; + case 'minor': + impactText = colors.gray(impactText); + break; + default: + impactText = colors.default(impactText); + break; + } + + append(` Impact: ${impactText}`); + append(` Description: ${violation.description}`); + append(` Help: ${violation.help}`); + append(` Help URL: ${colors.underline(colors.blue(violation.helpUrl))}\n`); + + if (violation.nodes && violation.nodes.length > 0) { + append(' Failing Elements:'); + violation.nodes.forEach((node, nodeIndex) => { + append(colors.cyan(` Node ${nodeIndex + 1}:`)); + if (node.target && node.target.length > 0) { + append(` Selector: ${colors.magenta(node.target.join(', '))}`); + } + if (node.failureSummary) { + append(' Failure Summary:'); + node.failureSummary + .split('\n') + .forEach(line => append(colors.yellow(` ${line.trim()}`))); + } + }); + } + }); + + fail(reportLines.join('\n')); +} + +describe('CdkRadioGroup', () => { + let fixture: ComponentFixture; + let radioGroup: DebugElement; + let radioButtons: DebugElement[]; + let radioGroupInstance: CdkRadioGroup; + let radioGroupElement: HTMLElement; + let radioButtonElements: HTMLElement[]; + + const keydown = (key: string) => { + radioGroupElement.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key})); + fixture.detectChanges(); + }; + + const click = (index: number) => { + radioButtonElements[index].dispatchEvent(new PointerEvent('pointerdown', {bubbles: true})); + fixture.detectChanges(); + }; + + const space = () => keydown(' '); + const enter = () => keydown('Enter'); + const up = () => keydown('ArrowUp'); + const down = () => keydown('ArrowDown'); + const left = () => keydown('ArrowLeft'); + const right = () => keydown('ArrowRight'); + const home = () => keydown('Home'); + const end = () => keydown('End'); + + function setupRadioGroup(opts?: { + orientation?: 'horizontal' | 'vertical'; + disabled?: boolean; + readonly?: boolean; + value?: number | null; + skipDisabled?: boolean; + focusMode?: 'roving' | 'activedescendant'; + disabledOptions?: number[]; + options?: TestOption[]; + textDirection?: Direction; + }) { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality(opts?.textDirection ?? 'ltr')], + imports: [BidiModule, RadioGroupExample], + }).compileComponents(); + + fixture = TestBed.createComponent(RadioGroupExample); + const testComponent = fixture.componentInstance; + + if (opts?.orientation !== undefined) { + testComponent.orientation = opts.orientation; + } + if (opts?.disabled !== undefined) { + testComponent.disabled = opts.disabled; + } + if (opts?.readonly !== undefined) { + testComponent.readonly = opts.readonly; + } + if (opts?.value !== undefined) { + testComponent.value = opts.value; + } + if (opts?.skipDisabled !== undefined) { + testComponent.skipDisabled = opts.skipDisabled; + } + if (opts?.focusMode !== undefined) { + testComponent.focusMode = opts.focusMode; + } + if (opts?.options !== undefined) { + testComponent.options.set(opts.options); + } + if (opts?.disabledOptions !== undefined) { + opts.disabledOptions.forEach(index => { + testComponent.options()[index].disabled = true; + }); + } + + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function setupDefaultRadioGroup() { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr')], + imports: [BidiModule, DefaultRadioGroupExample], + }).compileComponents(); + + const fixture = TestBed.createComponent(DefaultRadioGroupExample); + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function defineTestVariables(fixture: ComponentFixture) { + radioGroup = fixture.debugElement.query(By.directive(CdkRadioGroup)); + radioButtons = fixture.debugElement.queryAll(By.directive(CdkRadioButton)); + radioGroupInstance = radioGroup.injector.get>(CdkRadioGroup); + radioGroupElement = radioGroup.nativeElement; + radioButtonElements = radioButtons.map(radioButton => radioButton.nativeElement); + } + + afterEach(async () => { + await runAccessibilityChecks(radioGroupElement); + }); + + describe('ARIA attributes and roles', () => { + describe('default configuration', () => { + it('should correctly set the role attribute to "radiogroup"', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('role')).toBe('radiogroup'); + }); + + it('should correctly set the role attribute to "radio" for the radio buttons', () => { + setupDefaultRadioGroup(); + radioButtonElements.forEach(radioButtonElement => { + expect(radioButtonElement.getAttribute('role')).toBe('radio'); + }); + }); + + it('should set aria-orientation to "horizontal"', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('aria-orientation')).toBe('horizontal'); + }); + + it('should set aria-disabled to false', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set aria-readonly to false', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('aria-readonly')).toBe('false'); + }); + }); + + describe('custom configuration', () => { + it('should be able to set aria-orientation to "vertical"', () => { + setupRadioGroup({orientation: 'vertical'}); + expect(radioGroupElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should be able to set aria-disabled to true', () => { + setupRadioGroup({disabled: true}); + expect(radioGroupElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should be able to set aria-readonly to true', () => { + setupRadioGroup({readonly: true}); + expect(radioGroupElement.getAttribute('aria-readonly')).toBe('true'); + }); + }); + + describe('roving focus mode', () => { + it('should have tabindex="-1" when focusMode is "roving"', () => { + setupRadioGroup({focusMode: 'roving'}); + expect(radioGroupElement.getAttribute('tabindex')).toBe('-1'); + }); + + it('should set tabindex="0" when disabled', () => { + setupRadioGroup({disabled: true, focusMode: 'roving'}); + expect(radioGroupElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus on the selected option', () => { + setupRadioGroup({focusMode: 'roving', value: 3}); + expect(radioButtonElements[3].getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus on the first option if none are selected', () => { + setupRadioGroup({focusMode: 'roving'}); + expect(radioButtonElements[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should not have aria-activedescendant when focusMode is "roving"', () => { + setupRadioGroup({focusMode: 'roving'}); + expect(radioGroupElement.getAttribute('aria-activedescendant')).toBeNull(); + }); + }); + + describe('activedescendant focus mode', () => { + it('should have tabindex="0"', () => { + setupRadioGroup({focusMode: 'activedescendant'}); + expect(radioGroupElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus on the selected option', () => { + setupRadioGroup({focusMode: 'activedescendant', value: 3}); + expect(radioGroupElement.getAttribute('aria-activedescendant')).toBe( + radioButtonElements[3].id, + ); + }); + + it('should set initial focus on the first option if none are selected', () => { + setupRadioGroup({focusMode: 'activedescendant'}); + expect(radioGroupElement.getAttribute('aria-activedescendant')).toBe( + radioButtonElements[0].id, + ); + }); + }); + }); + + describe('value and selection', () => { + it('should select the radio button corresponding to the value input', () => { + setupRadioGroup(); + radioGroupInstance.value.set(1); + fixture.detectChanges(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the value model when the value of a radio group is changed through the ui', () => { + setupRadioGroup(); + click(1); + expect(radioGroupInstance.value()).toBe(1); + }); + + describe('pointer interaction', () => { + it('should update the group value when a radio button is selected via pointer click', () => { + setupRadioGroup(); + click(1); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should only allow one radio button to be selected at a time', () => { + setupRadioGroup(); + click(1); + click(2); + expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('false'); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + expect(radioButtonElements[2].getAttribute('aria-checked')).toBe('true'); + expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false'); + expect(radioButtonElements[4].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if the radio group is readonly', () => { + setupRadioGroup({readonly: true}); + click(3); + expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if the radio group is disabled', () => { + setupRadioGroup({disabled: true}); + click(3); + expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if a disabled radio button is clicked', () => { + setupRadioGroup({disabledOptions: [2]}); + click(2); + expect(radioButtonElements[2].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if a radio button is clicked in a readonly group', () => { + setupRadioGroup({readonly: true}); + click(1); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + }); + }); + + describe('keyboard interaction', () => { + it('should update the group value on Space', () => { + setupRadioGroup(); + space(); + expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on Enter', () => { + setupRadioGroup(); + enter(); + expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('true'); + }); + + it('should not change the value if the radio group is readonly', () => { + setupRadioGroup({orientation: 'horizontal', readonly: true}); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if the radio group is disabled', () => { + setupRadioGroup({orientation: 'horizontal', disabled: true}); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + }); + + describe('horizontal orientation', () => { + it('should update the group value on ArrowRight', () => { + setupRadioGroup({orientation: 'horizontal'}); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowLeft', () => { + setupRadioGroup({orientation: 'horizontal'}); + right(); + right(); + left(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + describe('text direction rtl', () => { + it('should update the group value on ArrowLeft', () => { + setupRadioGroup({orientation: 'horizontal', textDirection: 'rtl'}); + left(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowRight', () => { + setupRadioGroup({orientation: 'horizontal', textDirection: 'rtl'}); + left(); + left(); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + }); + }); + + describe('vertical orientation', () => { + it('should update the group value on ArrowDown', () => { + setupRadioGroup({orientation: 'vertical'}); + down(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowUp', () => { + setupRadioGroup({orientation: 'vertical'}); + down(); + down(); + up(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + }); + }); + }); + + function runNavigationTests( + focusMode: 'activedescendant' | 'roving', + isFocused: (index: number) => boolean, + ) { + describe(`keyboard navigation (focusMode="${focusMode}")`, () => { + it('should move focus to and select the last enabled radio button on End', () => { + setupRadioGroup({focusMode}); + end(); + expect(isFocused(4)).toBe(true); + }); + + it('should move focus to and select the first enabled radio button on Home', () => { + setupRadioGroup({focusMode}); + end(); + home(); + expect(isFocused(0)).toBe(true); + }); + + it('should not allow keyboard navigation or selection if the group is disabled', () => { + setupRadioGroup({focusMode, orientation: 'horizontal', disabled: true}); + right(); + expect(isFocused(0)).toBe(false); + }); + + it('should allow keyboard navigation if the group is readonly', () => { + setupRadioGroup({focusMode, orientation: 'horizontal', readonly: true}); + right(); + expect(isFocused(1)).toBe(true); + }); + + describe('vertical orientation', () => { + it('should move focus to the next radio button on ArrowDown', () => { + setupRadioGroup({focusMode, orientation: 'vertical'}); + down(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowUp', () => { + setupRadioGroup({focusMode, orientation: 'vertical'}); + down(); + down(); + up(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons (skipDisabled="true")', () => { + setupRadioGroup({ + focusMode, + orientation: 'vertical', + skipDisabled: true, + disabledOptions: [1, 2], + }); + down(); + expect(isFocused(3)).toBe(true); + }); + + it('should not skip disabled radio buttons (skipDisabled="false")', () => { + setupRadioGroup({ + focusMode, + orientation: 'vertical', + skipDisabled: false, + disabledOptions: [1, 2], + }); + down(); + expect(isFocused(1)).toBe(true); + }); + }); + + describe('horizontal orientation', () => { + it('should move focus to the next radio button on ArrowRight', () => { + setupRadioGroup({focusMode, orientation: 'horizontal'}); + right(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowLeft', () => { + setupRadioGroup({focusMode, orientation: 'horizontal'}); + right(); + right(); + left(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons (skipDisabled="true")', () => { + setupRadioGroup({ + focusMode, + orientation: 'horizontal', + skipDisabled: true, + disabledOptions: [1, 2], + }); + right(); + expect(isFocused(3)).toBe(true); + }); + + it('should not skip disabled radio buttons (skipDisabled="false")', () => { + setupRadioGroup({ + focusMode, + orientation: 'horizontal', + skipDisabled: false, + disabledOptions: [1, 2], + }); + right(); + expect(isFocused(1)).toBe(true); + }); + + describe('text direction rtl', () => { + it('should move focus to the next radio button on ArrowLeft', () => { + setupRadioGroup({focusMode, textDirection: 'rtl', orientation: 'horizontal'}); + left(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowRight', () => { + setupRadioGroup({focusMode, textDirection: 'rtl', orientation: 'horizontal'}); + left(); + left(); + right(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons when navigating', () => { + setupRadioGroup({ + focusMode, + skipDisabled: true, + textDirection: 'rtl', + disabledOptions: [1, 2], + orientation: 'horizontal', + }); + left(); + expect(isFocused(3)).toBe(true); + }); + }); + }); + }); + + describe(`pointer navigation (focusMode="${focusMode}")`, () => { + it('should move focus to the clicked radio button', () => { + setupRadioGroup({focusMode}); + click(3); + expect(isFocused(3)).toBe(true); + }); + + it('should move focus to the clicked radio button if the group is disabled (skipDisabled="true")', () => { + setupRadioGroup({focusMode, skipDisabled: true, disabled: true}); + click(3); + expect(isFocused(3)).toBe(false); + }); + + it('should not move focus to the clicked radio button if the group is disabled (skipDisabled="false")', () => { + setupRadioGroup({focusMode, skipDisabled: true, disabled: true}); + click(3); + expect(isFocused(0)).toBe(false); + }); + + it('should move focus to the clicked radio button if the group is readonly', () => { + setupRadioGroup({focusMode, readonly: true}); + click(3); + expect(isFocused(3)).toBe(true); + }); + }); + } + + runNavigationTests('roving', i => { + return radioButtonElements[i].getAttribute('tabindex') === '0'; + }); + + runNavigationTests('activedescendant', i => { + return radioGroupElement.getAttribute('aria-activedescendant') === radioButtonElements[i].id; + }); + + describe('failure cases', () => { + it('should handle an empty set of radio buttons gracefully', () => { + setupRadioGroup({options: []}); + expect(radioButtons.length).toBe(0); + }); + }); +}); + +interface TestOption { + value: number; + label: string; + disabled: boolean; +} + +@Component({ + template: ` +
+ @for (option of options(); track option.value) { +
{{ option.label }}
+ } +
+ `, + imports: [CdkRadioGroup, CdkRadioButton], +}) +class RadioGroupExample { + options = signal([ + {value: 0, label: '0', disabled: false}, + {value: 1, label: '1', disabled: false}, + {value: 2, label: '2', disabled: false}, + {value: 3, label: '3', disabled: false}, + {value: 4, label: '4', disabled: false}, + ]); + + value: number | null = null; + disabled = false; + readonly = false; + skipDisabled = true; + focusMode: 'roving' | 'activedescendant' = 'roving'; + orientation: 'horizontal' | 'vertical' = 'horizontal'; +} + +@Component({ + template: ` +
+
0
+
1
+
2
+
+ `, + imports: [CdkRadioGroup, CdkRadioButton], +}) +class DefaultRadioGroupExample {} diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts new file mode 100644 index 000000000000..adb623261ef5 --- /dev/null +++ b/src/cdk-experimental/radio/radio.ts @@ -0,0 +1,199 @@ +/** + * @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 { + afterRenderEffect, + booleanAttribute, + computed, + contentChildren, + Directive, + ElementRef, + inject, + input, + linkedSignal, + model, + signal, + WritableSignal, +} from '@angular/core'; +import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns'; +import {Directionality} from '@angular/cdk/bidi'; +import {_IdGenerator} from '@angular/cdk/a11y'; + +// TODO: Move mapSignal to it's own file so it can be reused across components. + +/** + * Creates a new writable signal (signal V) whose value is connected to the given original + * writable signal (signal T) such that updating signal V updates signal T and vice-versa. + * + * This function establishes a two-way synchronization between the source signal and the new mapped + * signal. When the source signal changes, the mapped signal updates by applying the `transform` + * function. When the mapped signal is explicitly set or updated, the change is propagated back to + * the source signal by applying the `reverse` function. + */ +export function mapSignal( + originalSignal: WritableSignal, + operations: { + transform: (value: T) => V; + reverse: (value: V) => T; + }, +) { + const mappedSignal = linkedSignal(() => operations.transform(originalSignal())); + const updateMappedSignal = mappedSignal.update; + const setMappedSignal = mappedSignal.set; + + mappedSignal.set = (newValue: V) => { + setMappedSignal(newValue); + originalSignal.set(operations.reverse(newValue)); + }; + + mappedSignal.update = (updateFn: (value: V) => V) => { + updateMappedSignal(oldValue => updateFn(oldValue)); + originalSignal.update(oldValue => operations.reverse(updateFn(operations.transform(oldValue)))); + }; + + return mappedSignal; +} + +/** + * A radio button group container. + * + * Radio groups are used to group multiple radio buttons or radio group labels so they function as + * a single form control. The CdkRadioGroup is meant to be used in conjunction with CdkRadioButton + * as follows: + * + * ```html + *
+ * + * + * + *
+ * ``` + */ +@Directive({ + selector: '[cdkRadioGroup]', + exportAs: 'cdkRadioGroup', + host: { + 'role': 'radiogroup', + 'class': 'cdk-radio-group', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-readonly]': 'pattern.readonly()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-orientation]': 'pattern.orientation()', + '[attr.aria-activedescendant]': 'pattern.activedescendant()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'onFocus()', + }, +}) +export class CdkRadioGroup { + /** The CdkRadioButtons nested inside of the CdkRadioGroup. */ + private readonly _cdkRadioButtons = contentChildren(CdkRadioButton, {descendants: true}); + + /** A signal wrapper for directionality. */ + protected textDirection = inject(Directionality).valueSignal; + + /** The RadioButton UIPatterns of the child CdkRadioButtons. */ + protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern)); + + /** Whether the radio group is vertically or horizontally oriented. */ + orientation = input<'vertical' | 'horizontal'>('horizontal'); + + /** Whether disabled items in the group should be skipped when navigating. */ + skipDisabled = input(true, {transform: booleanAttribute}); + + /** The focus strategy used by the radio group. */ + focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** Whether the radio group is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** Whether the radio group is readonly. */ + readonly = input(false, {transform: booleanAttribute}); + + /** The value of the currently selected radio button. */ + value = model(null); + + /** The internal selection state for the radio group. */ + private readonly _value = mapSignal(this.value, { + transform: value => (value !== null ? [value] : []), + reverse: values => (values.length === 0 ? null : values[0]), + }); + + /** The RadioGroup UIPattern. */ + pattern: RadioGroupPattern = new RadioGroupPattern({ + ...this, + items: this.items, + value: this._value, + activeIndex: signal(0), + textDirection: this.textDirection, + }); + + /** Whether the radio group has received focus yet. */ + private _hasFocused = signal(false); + + constructor() { + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + } + + onFocus() { + this._hasFocused.set(true); + } +} + +/** A selectable radio button in a CdkRadioGroup. */ +@Directive({ + selector: '[cdkRadioButton]', + exportAs: 'cdkRadioButton', + host: { + 'role': 'radio', + 'class': 'cdk-radio-button', + '[class.cdk-active]': 'pattern.active()', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-checked]': 'pattern.selected()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[id]': 'pattern.id()', + }, +}) +export class CdkRadioButton { + /** A reference to the radio button element. */ + private readonly _elementRef = inject(ElementRef); + + /** The parent CdkRadioGroup. */ + private readonly _cdkRadioGroup = inject(CdkRadioGroup); + + /** A unique identifier for the radio button. */ + private readonly _generatedId = inject(_IdGenerator).getId('cdk-radio-button-'); + + /** A unique identifier for the radio button. */ + protected id = computed(() => this._generatedId); + + /** The value associated with the radio button. */ + protected value = input.required(); + + /** The parent RadioGroup UIPattern. */ + protected group = computed(() => this._cdkRadioGroup.pattern); + + /** A reference to the radio button element to be focused on navigation. */ + protected element = computed(() => this._elementRef.nativeElement); + + /** Whether the radio button is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The RadioButton UIPattern. */ + pattern = new RadioButtonPattern({ + ...this, + id: this.id, + value: this.value, + group: this.group, + element: this.element, + }); +} diff --git a/src/cdk-experimental/ui-patterns/radio/radio-group.ts b/src/cdk-experimental/ui-patterns/radio/radio-group.ts index 5d1332165fe9..79a9c4402ad6 100644 --- a/src/cdk-experimental/ui-patterns/radio/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio/radio-group.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal} from '@angular/core'; +import {computed} from '@angular/core'; import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; @@ -21,7 +21,7 @@ interface SelectOptions { } /** Represents the required inputs for a radio group. */ -export type RadioGroupInputs = ListNavigationInputs> & +export type RadioGroupInputs = Omit>, 'wrap'> & // Radio groups are always single-select. Omit, V>, 'multi' | 'selectionMode'> & ListFocusInputs> & { @@ -115,12 +115,15 @@ export class RadioGroupPattern { this.orientation = inputs.orientation; this.focusManager = new ListFocus(inputs); - this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); + this.navigation = new ListNavigation({ + ...inputs, + wrap: () => false, + focusManager: this.focusManager, + }); this.selection = new ListSelection({ ...inputs, - // Radio groups are always single-select and selection follows focus. - multi: signal(false), - selectionMode: signal('follow'), + multi: () => false, + selectionMode: () => 'follow', focusManager: this.focusManager, }); } diff --git a/src/cdk-experimental/ui-patterns/radio/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts index 1fb717c94dc2..19a3644372f6 100644 --- a/src/cdk-experimental/ui-patterns/radio/radio.spec.ts +++ b/src/cdk-experimental/ui-patterns/radio/radio.spec.ts @@ -33,7 +33,6 @@ describe('RadioGroup Pattern', () => { items: inputs.items, value: inputs.value ?? signal([]), activeIndex: inputs.activeIndex ?? signal(0), - wrap: inputs.wrap ?? signal(true), readonly: inputs.readonly ?? signal(false), disabled: inputs.disabled ?? signal(false), skipDisabled: inputs.skipDisabled ?? signal(true), @@ -137,23 +136,6 @@ describe('RadioGroup Pattern', () => { expect(radioGroup.inputs.activeIndex()).toBe(4); }); - it('should wrap navigation when wrap is true', () => { - const {radioGroup} = getDefaultPatterns({wrap: signal(true)}); - radioGroup.onKeydown(up()); - expect(radioGroup.inputs.activeIndex()).toBe(4); - radioGroup.onKeydown(down()); - expect(radioGroup.inputs.activeIndex()).toBe(0); - }); - - it('should not wrap navigation when wrap is false', () => { - const {radioGroup} = getDefaultPatterns({wrap: signal(false)}); - radioGroup.onKeydown(up()); - expect(radioGroup.inputs.activeIndex()).toBe(0); - radioGroup.onKeydown(end()); - radioGroup.onKeydown(down()); - expect(radioGroup.inputs.activeIndex()).toBe(4); - }); - it('should skip disabled radios when skipDisabled is true', () => { const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(true)}); radioButtons[1].disabled.set(true); diff --git a/src/cdk-experimental/ui-patterns/radio/radio.ts b/src/cdk-experimental/ui-patterns/radio/radio.ts index bf4e4ec61817..f9d7c2f9724f 100644 --- a/src/cdk-experimental/ui-patterns/radio/radio.ts +++ b/src/cdk-experimental/ui-patterns/radio/radio.ts @@ -51,7 +51,9 @@ export class RadioButtonPattern { active = computed(() => this.group()?.focusManager.activeItem() === this); /** Whether the radio button is selected. */ - selected = computed(() => this.group()?.selection.inputs.value().includes(this.value())); + selected: SignalLike = computed( + () => !!this.group()?.selection.inputs.value().includes(this.value()), + ); /** Whether the radio button is disabled. */ disabled: SignalLike; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy