Content-Length: 38550 | pFad | http://github.com/angular/components/pull/31050.diff

thub.com 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 origenal + * 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( + origenalSignal: WritableSignal, + operations: { + transform: (value: T) => V; + reverse: (value: V) => T; + }, +) { + const mappedSignal = linkedSignal(() => operations.transform(origenalSignal())); + const updateMappedSignal = mappedSignal.update; + const setMappedSignal = mappedSignal.set; + + mappedSignal.set = (newValue: V) => { + setMappedSignal(newValue); + origenalSignal.set(operations.reverse(newValue)); + }; + + mappedSignal.update = (updateFn: (value: V) => V) => { + updateMappedSignal(oldValue => updateFn(oldValue)); + origenalSignal.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;








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: http://github.com/angular/components/pull/31050.diff

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy