From 128ec6e875e2ca7a94c211d3e41c79a9297d8f1c Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 5 May 2025 15:10:47 -0400 Subject: [PATCH 01/10] feat(cdk-experimental/radio): create radio group and button directives --- .ng-dev/commit-message.mts | 1 + src/cdk-experimental/config.bzl | 1 + src/cdk-experimental/radio/BUILD.bazel | 17 +++ src/cdk-experimental/radio/index.ts | 9 ++ src/cdk-experimental/radio/public-api.ts | 9 ++ src/cdk-experimental/radio/radio.ts | 178 +++++++++++++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 src/cdk-experimental/radio/BUILD.bazel create mode 100644 src/cdk-experimental/radio/index.ts create mode 100644 src/cdk-experimental/radio/public-api.ts create mode 100644 src/cdk-experimental/radio/radio.ts 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/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..f9b2a2571ab0 --- /dev/null +++ b/src/cdk-experimental/radio/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ng_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", + ], +) 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.ts b/src/cdk-experimental/radio/radio.ts new file mode 100644 index 000000000000..c30b7f28fefa --- /dev/null +++ b/src/cdk-experimental/radio/radio.ts @@ -0,0 +1,178 @@ +/** + * @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 { + AfterViewInit, + booleanAttribute, + computed, + contentChildren, + Directive, + effect, + ElementRef, + inject, + input, + linkedSignal, + model, + signal, +} from '@angular/core'; +import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns'; +import {Directionality} from '@angular/cdk/bidi'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {_IdGenerator} from '@angular/cdk/a11y'; + +/** + * 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 implements AfterViewInit { + /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ + private readonly _directionality = inject(Directionality); + + /** The CdkRadioButtons nested inside of the CdkRadioGroup. */ + private readonly _cdkRadioButtons = contentChildren(CdkRadioButton, {descendants: true}); + + /** A signal wrapper for directionality. */ + protected textDirection = toSignal(this._directionality.change, { + initialValue: this._directionality.value, + }); + + /** 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'>('vertical'); + + /** Whether focus should wrap when navigating. */ + wrap = input(false, {transform: booleanAttribute}); // Radio groups typically don't wrap + + /** 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 = linkedSignal(() => (this.value() ? [this.value()!] : [])); + + /** The current index that has been navigated to. */ + activeIndex = model(0); + + /** The RadioGroup UIPattern. */ + pattern: RadioGroupPattern = new RadioGroupPattern({ + ...this, + items: this.items, + value: this._value, + textDirection: this.textDirection, + }); + + /** Whether the radio group has received focus yet. */ + private _hasFocused = signal(false); + + /** Whether the radio buttons in the group have been initialized. */ + private _isViewInitialized = signal(false); + + constructor() { + effect(() => { + if (this._isViewInitialized() && !this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + } + + ngAfterViewInit() { + this._isViewInitialized.set(true); + } + + 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()', + }, +}) +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, + }); +} From e0c464b845e2992e72ee02130812ee66ea1e119b Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 13 May 2025 12:37:13 -0400 Subject: [PATCH 02/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- src/cdk-experimental/radio/BUILD.bazel | 21 +- src/cdk-experimental/radio/radio.spec.ts | 550 +++++++++++++++++++++++ src/cdk-experimental/radio/radio.ts | 7 +- 3 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 src/cdk-experimental/radio/radio.spec.ts diff --git a/src/cdk-experimental/radio/BUILD.bazel b/src/cdk-experimental/radio/BUILD.bazel index f9b2a2571ab0..4f169593633a 100644 --- a/src/cdk-experimental/radio/BUILD.bazel +++ b/src/cdk-experimental/radio/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_project") +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -15,3 +15,22 @@ ng_project( "//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", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts new file mode 100644 index 000000000000..d839bb506cb2 --- /dev/null +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -0,0 +1,550 @@ +import {Component, DebugElement, EventEmitter, signal, Type, WritableSignal} from '@angular/core'; +import {CdkRadioButton, CdkRadioGroup} from './radio'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule, Direction, Directionality} from '@angular/cdk/bidi'; + +describe('CdkRadioGroup', () => { + let fixture: ComponentFixture; + let textDirection = new EventEmitter(); + + 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 setupTestEnvironment(component: Type) { + TestBed.configureTestingModule({ + providers: [ + { + provide: Directionality, + useValue: {value: 'ltr', change: textDirection}, + }, + ], + imports: [BidiModule, component], + }).compileComponents(); + + const fixture = TestBed.createComponent(component); + fixture.detectChanges(); + + radioGroup = fixture.debugElement.query(By.directive(CdkRadioGroup)); + radioButtons = radioGroup.queryAll(By.directive(CdkRadioButton)); + radioGroupInstance = radioGroup.injector.get>(CdkRadioGroup); + radioGroupElement = radioGroup.nativeElement; + radioButtonElements = radioButtons.map(radioButton => radioButton.nativeElement); + + return fixture; + } + + function setupRadioGroup(opts?: { + orientation?: 'horizontal' | 'vertical'; + disabled?: boolean; + readonly?: boolean; + value?: number | null; + skipDisabled?: boolean; + focusMode?: 'roving' | 'activedescendant'; + disabledOptions?: number[]; + options?: TestOption[]; + textDirection?: Direction; + }) { + const testComponent = fixture.componentInstance; + + if (opts?.orientation !== undefined) { + testComponent.orientation.set(opts.orientation); + } + if (opts?.disabled !== undefined) { + testComponent.disabled.set(opts.disabled); + } + if (opts?.readonly !== undefined) { + testComponent.readonly.set(opts.readonly); + } + if (opts?.value !== undefined) { + testComponent.value.set(opts.value); + } + if (opts?.skipDisabled !== undefined) { + testComponent.skipDisabled.set(opts.skipDisabled); + } + if (opts?.focusMode !== undefined) { + testComponent.focusMode.set(opts.focusMode); + } + if (opts?.options !== undefined) { + testComponent.options.set(opts.options); + } + if (opts?.disabledOptions !== undefined) { + opts.disabledOptions.forEach(index => { + testComponent.options()[index].disabled.set(true); + }); + } + if (opts?.textDirection !== undefined) { + textDirection.emit(opts.textDirection); + } + fixture.detectChanges(); + } + + describe('ARIA attributes and roles', () => { + describe('default configuration', () => { + beforeEach(() => { + setupTestEnvironment(DefaultRadioGroupExample); + }); + + it('should correctly set the role attribute to "radiogroup"', () => { + expect(radioGroupElement.getAttribute('role')).toBe('radiogroup'); + }); + + it('should correctly set the role attribute to "radio" for the radio buttons', () => { + radioButtonElements.forEach(radioButtonElement => { + expect(radioButtonElement.getAttribute('role')).toBe('radio'); + }); + }); + + it('should set aria-orientation to "horizontal"', () => { + expect(radioGroupElement.getAttribute('aria-orientation')).toBe('horizontal'); + }); + + it('should set aria-disabled to false', () => { + expect(radioGroupElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set aria-readonly to false', () => { + expect(radioGroupElement.getAttribute('aria-readonly')).toBe('false'); + }); + }); + + describe('custom configuration', () => { + beforeEach(() => { + fixture = setupTestEnvironment(RadioGroupExample); + }); + + 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', () => { + beforeEach(() => { + fixture = setupTestEnvironment(RadioGroupExample); + }); + + 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', () => { + beforeEach(() => { + fixture = setupTestEnvironment(RadioGroupExample); + }); + + 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', () => { + beforeEach(() => { + fixture = setupTestEnvironment(RadioGroupExample); + }); + + it('should select the radio button corresponding to the value input', () => { + radioGroupInstance.value.set(1); + fixture.detectChanges(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + describe('pointer interaction', () => { + it('should update the group value when a radio button is selected via pointer click', () => { + click(1); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should only allow one radio button to be selected at a time', () => { + 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', () => { + space(); + expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on Enter', () => { + 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', () => { + beforeEach(() => setupRadioGroup({orientation: 'horizontal'})); + + it('should update the group value on ArrowRight', () => { + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowLeft', () => { + right(); + right(); + left(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + describe('text direction rtl', () => { + beforeEach(() => setupRadioGroup({textDirection: 'rtl'})); + + it('should update the group value on ArrowLeft', () => { + left(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowRight', () => { + left(); + left(); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + }); + }); + + describe('vertical orientation', () => { + beforeEach(() => setupRadioGroup({orientation: 'vertical'})); + + it('should update the group value on ArrowDown', () => { + down(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowUp', () => { + 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}")`, () => { + beforeEach(() => { + fixture = setupTestEnvironment(RadioGroupExample); + setupRadioGroup({focusMode}); + }); + + it('should move focus to and select the last enabled radio button on End', () => { + end(); + expect(isFocused(4)).toBe(true); + }); + + it('should move focus to and select the first enabled radio button on Home', () => { + end(); + home(); + expect(isFocused(0)).toBe(true); + }); + + it('should not allow keyboard navigation or selection if the group is disabled', () => { + setupRadioGroup({orientation: 'horizontal', disabled: true}); + right(); + expect(isFocused(0)).toBe(false); + }); + + it('should allow keyboard navigation if the group is readonly', () => { + setupRadioGroup({orientation: 'horizontal', readonly: true}); + right(); + expect(isFocused(1)).toBe(true); + }); + + describe('vertical orientation', () => { + beforeEach(() => setupRadioGroup({orientation: 'vertical'})); + + it('should move focus to the next radio button on ArrowDown', () => { + down(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowUp', () => { + down(); + down(); + up(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons (skipDisabled="true")', () => { + setupRadioGroup({skipDisabled: true, disabledOptions: [1, 2]}); + down(); + expect(isFocused(3)).toBe(true); + }); + + it('should not skip disabled radio buttons (skipDisabled="false")', () => { + setupRadioGroup({skipDisabled: false, disabledOptions: [1, 2]}); + down(); + expect(isFocused(1)).toBe(true); + }); + }); + + describe('horizontal orientation', () => { + beforeEach(() => setupRadioGroup({orientation: 'horizontal'})); + + it('should move focus to the next radio button on ArrowRight', () => { + right(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowLeft', () => { + right(); + right(); + left(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons (skipDisabled="true")', () => { + setupRadioGroup({skipDisabled: true, disabledOptions: [1, 2]}); + right(); + expect(isFocused(3)).toBe(true); + }); + + it('should not skip disabled radio buttons (skipDisabled="false")', () => { + setupRadioGroup({skipDisabled: false, disabledOptions: [1, 2]}); + right(); + expect(isFocused(1)).toBe(true); + }); + + describe('text direction rtl', () => { + beforeEach(() => setupRadioGroup({textDirection: 'rtl'})); + + it('should move focus to the next radio button on ArrowLeft', () => { + setupRadioGroup({orientation: 'horizontal'}); + left(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowRight', () => { + setupRadioGroup({orientation: 'horizontal'}); + left(); + left(); + right(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons when navigating', () => { + setupRadioGroup({ + skipDisabled: true, + disabledOptions: [1, 2], + orientation: 'horizontal', + }); + left(); + expect(isFocused(3)).toBe(true); + }); + }); + }); + }); + + describe(`pointer navigation (focusMode="${focusMode}")`, () => { + beforeEach(() => { + fixture = setupTestEnvironment(RadioGroupExample); + setupRadioGroup({focusMode}); + }); + + it('should move focus to the clicked radio button', () => { + click(3); + expect(isFocused(3)).toBe(true); + }); + + it('should move focus to the clicked radio button if the group is disabled (skipDisabled="true")', () => { + setupRadioGroup({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({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({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; + }); + + it('should handle an empty set of radio buttons gracefully', () => { + setupRadioGroup({options: []}); + radioButtons = fixture.debugElement.queryAll(By.directive(CdkRadioButton)); + expect(radioButtons.length).toBe(0); + }); +}); + +interface TestOption { + value: number; + label: string; + disabled: WritableSignal; +} + +@Component({ + template: ` +
+ @for (option of options(); track option.value) { +
{{ option.label }}
+ } +
+ `, + imports: [CdkRadioGroup, CdkRadioButton], +}) +class RadioGroupExample { + options = signal([ + {value: 0, label: '0', disabled: signal(false)}, + {value: 1, label: '1', disabled: signal(false)}, + {value: 2, label: '2', disabled: signal(false)}, + {value: 3, label: '3', disabled: signal(false)}, + {value: 4, label: '4', disabled: signal(false)}, + ]); + + disabled = signal(false); + readonly = signal(false); + value = signal(null); + skipDisabled = signal(true); + focusMode = signal<'roving' | 'activedescendant'>('roving'); + orientation = signal<'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 index c30b7f28fefa..2677a8b75ca9 100644 --- a/src/cdk-experimental/radio/radio.ts +++ b/src/cdk-experimental/radio/radio.ts @@ -72,7 +72,7 @@ export class CdkRadioGroup implements AfterViewInit { protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern)); /** Whether the radio group is vertically or horizontally oriented. */ - orientation = input<'vertical' | 'horizontal'>('vertical'); + orientation = input<'vertical' | 'horizontal'>('horizontal'); /** Whether focus should wrap when navigating. */ wrap = input(false, {transform: booleanAttribute}); // Radio groups typically don't wrap @@ -95,14 +95,12 @@ export class CdkRadioGroup implements AfterViewInit { /** The internal selection state for the radio group. */ private readonly _value = linkedSignal(() => (this.value() ? [this.value()!] : [])); - /** The current index that has been navigated to. */ - activeIndex = model(0); - /** The RadioGroup UIPattern. */ pattern: RadioGroupPattern = new RadioGroupPattern({ ...this, items: this.items, value: this._value, + activeIndex: signal(0), textDirection: this.textDirection, }); @@ -140,6 +138,7 @@ export class CdkRadioGroup implements AfterViewInit { '[attr.tabindex]': 'pattern.tabindex()', '[attr.aria-checked]': 'pattern.selected()', '[attr.aria-disabled]': 'pattern.disabled()', + '[id]': 'pattern.id()', }, }) export class CdkRadioButton { From 7d6a9b4ec73f8d62397605d3f9b87123eb5e6912 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 13 May 2025 13:00:23 -0400 Subject: [PATCH 03/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- src/cdk-experimental/radio/radio.ts | 15 ++++----------- .../ui-patterns/radio/radio-group.ts | 13 ++++++++----- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts index 2677a8b75ca9..bdbd1771ce55 100644 --- a/src/cdk-experimental/radio/radio.ts +++ b/src/cdk-experimental/radio/radio.ts @@ -7,12 +7,12 @@ */ import { + afterRenderEffect, AfterViewInit, booleanAttribute, computed, contentChildren, Directive, - effect, ElementRef, inject, input, @@ -56,7 +56,7 @@ import {_IdGenerator} from '@angular/cdk/a11y'; '(focusin)': 'onFocus()', }, }) -export class CdkRadioGroup implements AfterViewInit { +export class CdkRadioGroup { /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ private readonly _directionality = inject(Directionality); @@ -74,9 +74,6 @@ export class CdkRadioGroup implements AfterViewInit { /** Whether the radio group is vertically or horizontally oriented. */ orientation = input<'vertical' | 'horizontal'>('horizontal'); - /** Whether focus should wrap when navigating. */ - wrap = input(false, {transform: booleanAttribute}); // Radio groups typically don't wrap - /** Whether disabled items in the group should be skipped when navigating. */ skipDisabled = input(true, {transform: booleanAttribute}); @@ -111,17 +108,13 @@ export class CdkRadioGroup implements AfterViewInit { private _isViewInitialized = signal(false); constructor() { - effect(() => { - if (this._isViewInitialized() && !this._hasFocused()) { + afterRenderEffect(() => { + if (!this._hasFocused()) { this.pattern.setDefaultState(); } }); } - ngAfterViewInit() { - this._isViewInitialized.set(true); - } - onFocus() { this._hasFocused.set(true); } diff --git a/src/cdk-experimental/ui-patterns/radio/radio-group.ts b/src/cdk-experimental/ui-patterns/radio/radio-group.ts index 5d1332165fe9..f5cd7b0cd11d 100644 --- a/src/cdk-experimental/ui-patterns/radio/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio/radio-group.ts @@ -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, }); } From 16027e28410c80e6c979b9a95f854afcd242bb3c Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 13 May 2025 13:22:48 -0400 Subject: [PATCH 04/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- src/cdk-experimental/radio/radio.ts | 4 ---- src/cdk-experimental/ui-patterns/radio/radio-group.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts index bdbd1771ce55..2aa351679180 100644 --- a/src/cdk-experimental/radio/radio.ts +++ b/src/cdk-experimental/radio/radio.ts @@ -8,7 +8,6 @@ import { afterRenderEffect, - AfterViewInit, booleanAttribute, computed, contentChildren, @@ -104,9 +103,6 @@ export class CdkRadioGroup { /** Whether the radio group has received focus yet. */ private _hasFocused = signal(false); - /** Whether the radio buttons in the group have been initialized. */ - private _isViewInitialized = signal(false); - constructor() { afterRenderEffect(() => { if (!this._hasFocused()) { diff --git a/src/cdk-experimental/ui-patterns/radio/radio-group.ts b/src/cdk-experimental/ui-patterns/radio/radio-group.ts index f5cd7b0cd11d..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'; From f3ed60b13e8bc161c55883b0142ac57cf8d18a16 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 13 May 2025 16:17:17 -0400 Subject: [PATCH 05/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- package.json | 1 + pnpm-lock.yaml | 9 ++ src/cdk-experimental/radio/BUILD.bazel | 1 + src/cdk-experimental/radio/radio.spec.ts | 103 +++++++++++++++++++++-- 4 files changed, 107 insertions(+), 7 deletions(-) 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/radio/BUILD.bazel b/src/cdk-experimental/radio/BUILD.bazel index 4f169593633a..039e484b5a05 100644 --- a/src/cdk-experimental/radio/BUILD.bazel +++ b/src/cdk-experimental/radio/BUILD.bazel @@ -27,6 +27,7 @@ ts_project( ":radio", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", + "//:node_modules/axe-core", ], ) diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts index d839bb506cb2..f8d511b35ab5 100644 --- a/src/cdk-experimental/radio/radio.spec.ts +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -3,6 +3,79 @@ import {CdkRadioButton, CdkRadioGroup} from './radio'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {BidiModule, Direction, Directionality} from '@angular/cdk/bidi'; +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 getAccessibilityViolationsReport(root: HTMLElement): Promise { + const results = await axe.run(root); + + if (!results.violations.length) { + return null; + } + + 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()}`))); + } + }); + } + }); + + return reportLines.join('\n'); +} describe('CdkRadioGroup', () => { let fixture: ComponentFixture; @@ -46,14 +119,16 @@ describe('CdkRadioGroup', () => { const fixture = TestBed.createComponent(component); fixture.detectChanges(); + defineTestVariables(fixture); + return fixture; + } + function defineTestVariables(fixture: ComponentFixture) { radioGroup = fixture.debugElement.query(By.directive(CdkRadioGroup)); - radioButtons = radioGroup.queryAll(By.directive(CdkRadioButton)); + radioButtons = fixture.debugElement.queryAll(By.directive(CdkRadioButton)); radioGroupInstance = radioGroup.injector.get>(CdkRadioGroup); radioGroupElement = radioGroup.nativeElement; radioButtonElements = radioButtons.map(radioButton => radioButton.nativeElement); - - return fixture; } function setupRadioGroup(opts?: { @@ -99,8 +174,17 @@ describe('CdkRadioGroup', () => { textDirection.emit(opts.textDirection); } fixture.detectChanges(); + defineTestVariables(fixture); // Ensure env vars are up-to-date with the dom. } + afterEach(async () => { + const report = await getAccessibilityViolationsReport(radioGroupElement); + + if (report) { + fail(report); + } + }); + describe('ARIA attributes and roles', () => { describe('default configuration', () => { beforeEach(() => { @@ -490,10 +574,15 @@ describe('CdkRadioGroup', () => { return radioGroupElement.getAttribute('aria-activedescendant') === radioButtonElements[i].id; }); - it('should handle an empty set of radio buttons gracefully', () => { - setupRadioGroup({options: []}); - radioButtons = fixture.debugElement.queryAll(By.directive(CdkRadioButton)); - expect(radioButtons.length).toBe(0); + describe('failure cases', () => { + beforeEach(() => { + fixture = setupTestEnvironment(RadioGroupExample); + }); + + it('should handle an empty set of radio buttons gracefully', () => { + setupRadioGroup({options: []}); + expect(radioButtons.length).toBe(0); + }); }); }); From e849627eee80724c34edafa56d600002fe92c4be Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 13 May 2025 16:20:06 -0400 Subject: [PATCH 06/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- .../ui-patterns/radio/radio.spec.ts | 18 ------------------ 1 file changed, 18 deletions(-) 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); From 399baa32b17e0bd377fd70645e9c0d6db4c80081 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 13 May 2025 17:12:52 -0400 Subject: [PATCH 07/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- src/cdk-experimental/radio/radio.spec.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts index f8d511b35ab5..8c5e4f539cc0 100644 --- a/src/cdk-experimental/radio/radio.spec.ts +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -18,11 +18,11 @@ const colors = { }; // TODO: Move this to a separate folder/file so it can be reused across components. -async function getAccessibilityViolationsReport(root: HTMLElement): Promise { +async function runAccessibilityChecks(root: HTMLElement): Promise { const results = await axe.run(root); if (!results.violations.length) { - return null; + return; } const reportLines: string[] = []; @@ -74,7 +74,7 @@ async function getAccessibilityViolationsReport(root: HTMLElement): Promise { @@ -178,11 +178,7 @@ describe('CdkRadioGroup', () => { } afterEach(async () => { - const report = await getAccessibilityViolationsReport(radioGroupElement); - - if (report) { - fail(report); - } + await runAccessibilityChecks(radioGroupElement); }); describe('ARIA attributes and roles', () => { From acab43c9b189c1d2826667039e30abef93386946 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 13 May 2025 19:21:04 -0400 Subject: [PATCH 08/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- src/cdk-experimental/radio/radio.spec.ts | 14 ++++++-------- src/cdk-experimental/radio/radio.ts | 8 +------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts index 8c5e4f539cc0..694aae9f326a 100644 --- a/src/cdk-experimental/radio/radio.spec.ts +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -1,4 +1,4 @@ -import {Component, DebugElement, EventEmitter, signal, Type, WritableSignal} from '@angular/core'; +import {Component, DebugElement, signal, Type, WritableSignal} from '@angular/core'; import {CdkRadioButton, CdkRadioGroup} from './radio'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; @@ -79,7 +79,7 @@ async function runAccessibilityChecks(root: HTMLElement): Promise { describe('CdkRadioGroup', () => { let fixture: ComponentFixture; - let textDirection = new EventEmitter(); + let textDirection = signal('ltr'); let radioGroup: DebugElement; let radioButtons: DebugElement[]; @@ -111,7 +111,7 @@ describe('CdkRadioGroup', () => { providers: [ { provide: Directionality, - useValue: {value: 'ltr', change: textDirection}, + useFactory: () => ({valueSignal: textDirection}), }, ], imports: [BidiModule, component], @@ -120,6 +120,7 @@ describe('CdkRadioGroup', () => { const fixture = TestBed.createComponent(component); fixture.detectChanges(); defineTestVariables(fixture); + textDirection.set('ltr'); return fixture; } @@ -171,7 +172,7 @@ describe('CdkRadioGroup', () => { }); } if (opts?.textDirection !== undefined) { - textDirection.emit(opts.textDirection); + textDirection.set(opts.textDirection); } fixture.detectChanges(); defineTestVariables(fixture); // Ensure env vars are up-to-date with the dom. @@ -502,16 +503,14 @@ describe('CdkRadioGroup', () => { }); describe('text direction rtl', () => { - beforeEach(() => setupRadioGroup({textDirection: 'rtl'})); + beforeEach(() => setupRadioGroup({textDirection: 'rtl', orientation: 'horizontal'})); it('should move focus to the next radio button on ArrowLeft', () => { - setupRadioGroup({orientation: 'horizontal'}); left(); expect(isFocused(1)).toBe(true); }); it('should move focus to the previous radio button on ArrowRight', () => { - setupRadioGroup({orientation: 'horizontal'}); left(); left(); right(); @@ -522,7 +521,6 @@ describe('CdkRadioGroup', () => { setupRadioGroup({ skipDisabled: true, disabledOptions: [1, 2], - orientation: 'horizontal', }); left(); expect(isFocused(3)).toBe(true); diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts index 2aa351679180..5f15ecb7dc04 100644 --- a/src/cdk-experimental/radio/radio.ts +++ b/src/cdk-experimental/radio/radio.ts @@ -21,7 +21,6 @@ import { } from '@angular/core'; import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; -import {toSignal} from '@angular/core/rxjs-interop'; import {_IdGenerator} from '@angular/cdk/a11y'; /** @@ -56,16 +55,11 @@ import {_IdGenerator} from '@angular/cdk/a11y'; }, }) export class CdkRadioGroup { - /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ - private readonly _directionality = inject(Directionality); - /** The CdkRadioButtons nested inside of the CdkRadioGroup. */ private readonly _cdkRadioButtons = contentChildren(CdkRadioButton, {descendants: true}); /** A signal wrapper for directionality. */ - protected textDirection = toSignal(this._directionality.change, { - initialValue: this._directionality.value, - }); + protected textDirection = inject(Directionality).valueSignal; /** The RadioButton UIPatterns of the child CdkRadioButtons. */ protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern)); From e40b4f29160ef3fcbdc268ce830e796cfd650148 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 14 May 2025 16:04:10 -0400 Subject: [PATCH 09/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- src/cdk-experimental/radio/BUILD.bazel | 1 + src/cdk-experimental/radio/radio.spec.ts | 234 +++++++++--------- src/cdk-experimental/radio/radio.ts | 41 ++- .../ui-patterns/radio/radio.ts | 4 +- 4 files changed, 163 insertions(+), 117 deletions(-) diff --git a/src/cdk-experimental/radio/BUILD.bazel b/src/cdk-experimental/radio/BUILD.bazel index 039e484b5a05..3838af15170b 100644 --- a/src/cdk-experimental/radio/BUILD.bazel +++ b/src/cdk-experimental/radio/BUILD.bazel @@ -28,6 +28,7 @@ ts_project( "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", "//:node_modules/axe-core", + "//src/cdk/testing/private", ], ) diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts index 694aae9f326a..ca58af93655f 100644 --- a/src/cdk-experimental/radio/radio.spec.ts +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -1,8 +1,9 @@ -import {Component, DebugElement, signal, Type, WritableSignal} from '@angular/core'; +import {Component, DebugElement, signal, WritableSignal} from '@angular/core'; import {CdkRadioButton, CdkRadioGroup} from './radio'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {BidiModule, Direction, Directionality} from '@angular/cdk/bidi'; +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. @@ -79,8 +80,6 @@ async function runAccessibilityChecks(root: HTMLElement): Promise { describe('CdkRadioGroup', () => { let fixture: ComponentFixture; - let textDirection = signal('ltr'); - let radioGroup: DebugElement; let radioButtons: DebugElement[]; let radioGroupInstance: CdkRadioGroup; @@ -106,32 +105,6 @@ describe('CdkRadioGroup', () => { const home = () => keydown('Home'); const end = () => keydown('End'); - function setupTestEnvironment(component: Type) { - TestBed.configureTestingModule({ - providers: [ - { - provide: Directionality, - useFactory: () => ({valueSignal: textDirection}), - }, - ], - imports: [BidiModule, component], - }).compileComponents(); - - const fixture = TestBed.createComponent(component); - fixture.detectChanges(); - defineTestVariables(fixture); - textDirection.set('ltr'); - return 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); - } - function setupRadioGroup(opts?: { orientation?: 'horizontal' | 'vertical'; disabled?: boolean; @@ -143,39 +116,62 @@ describe('CdkRadioGroup', () => { 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.set(opts.orientation); + testComponent.orientation = opts.orientation; } if (opts?.disabled !== undefined) { - testComponent.disabled.set(opts.disabled); + testComponent.disabled = opts.disabled; } if (opts?.readonly !== undefined) { - testComponent.readonly.set(opts.readonly); + testComponent.readonly = opts.readonly; } if (opts?.value !== undefined) { - testComponent.value.set(opts.value); + testComponent.value = opts.value; } if (opts?.skipDisabled !== undefined) { - testComponent.skipDisabled.set(opts.skipDisabled); + testComponent.skipDisabled = opts.skipDisabled; } if (opts?.focusMode !== undefined) { - testComponent.focusMode.set(opts.focusMode); + 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.set(true); + testComponent.options()[index].disabled = true; }); } - if (opts?.textDirection !== undefined) { - textDirection.set(opts.textDirection); - } + + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function setupDefaultRadioGroup() { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr')], + imports: [BidiModule, DefaultRadioGroupExample], + }).compileComponents(); + + const fixture = TestBed.createComponent(DefaultRadioGroupExample); fixture.detectChanges(); - defineTestVariables(fixture); // Ensure env vars are up-to-date with the dom. + 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 () => { @@ -184,38 +180,35 @@ describe('CdkRadioGroup', () => { describe('ARIA attributes and roles', () => { describe('default configuration', () => { - beforeEach(() => { - setupTestEnvironment(DefaultRadioGroupExample); - }); - 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', () => { - beforeEach(() => { - fixture = setupTestEnvironment(RadioGroupExample); - }); - it('should be able to set aria-orientation to "vertical"', () => { setupRadioGroup({orientation: 'vertical'}); expect(radioGroupElement.getAttribute('aria-orientation')).toBe('vertical'); @@ -233,10 +226,6 @@ describe('CdkRadioGroup', () => { }); describe('roving focus mode', () => { - beforeEach(() => { - fixture = setupTestEnvironment(RadioGroupExample); - }); - it('should have tabindex="-1" when focusMode is "roving"', () => { setupRadioGroup({focusMode: 'roving'}); expect(radioGroupElement.getAttribute('tabindex')).toBe('-1'); @@ -264,10 +253,6 @@ describe('CdkRadioGroup', () => { }); describe('activedescendant focus mode', () => { - beforeEach(() => { - fixture = setupTestEnvironment(RadioGroupExample); - }); - it('should have tabindex="0"', () => { setupRadioGroup({focusMode: 'activedescendant'}); expect(radioGroupElement.getAttribute('tabindex')).toBe('0'); @@ -290,23 +275,28 @@ describe('CdkRadioGroup', () => { }); describe('value and selection', () => { - beforeEach(() => { - fixture = setupTestEnvironment(RadioGroupExample); - }); - 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'); @@ -343,11 +333,13 @@ describe('CdkRadioGroup', () => { 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'); }); @@ -365,14 +357,14 @@ describe('CdkRadioGroup', () => { }); describe('horizontal orientation', () => { - beforeEach(() => setupRadioGroup({orientation: 'horizontal'})); - 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(); @@ -380,14 +372,14 @@ describe('CdkRadioGroup', () => { }); describe('text direction rtl', () => { - beforeEach(() => setupRadioGroup({textDirection: '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(); @@ -397,14 +389,14 @@ describe('CdkRadioGroup', () => { }); describe('vertical orientation', () => { - beforeEach(() => setupRadioGroup({orientation: 'vertical'})); - 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(); @@ -419,43 +411,40 @@ describe('CdkRadioGroup', () => { isFocused: (index: number) => boolean, ) { describe(`keyboard navigation (focusMode="${focusMode}")`, () => { - beforeEach(() => { - fixture = setupTestEnvironment(RadioGroupExample); - setupRadioGroup({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({orientation: 'horizontal', disabled: true}); + setupRadioGroup({focusMode, orientation: 'horizontal', disabled: true}); right(); expect(isFocused(0)).toBe(false); }); it('should allow keyboard navigation if the group is readonly', () => { - setupRadioGroup({orientation: 'horizontal', readonly: true}); + setupRadioGroup({focusMode, orientation: 'horizontal', readonly: true}); right(); expect(isFocused(1)).toBe(true); }); describe('vertical orientation', () => { - beforeEach(() => setupRadioGroup({orientation: 'vertical'})); - 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(); @@ -463,27 +452,37 @@ describe('CdkRadioGroup', () => { }); it('should skip disabled radio buttons (skipDisabled="true")', () => { - setupRadioGroup({skipDisabled: true, disabledOptions: [1, 2]}); + 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({skipDisabled: false, disabledOptions: [1, 2]}); + setupRadioGroup({ + focusMode, + orientation: 'vertical', + skipDisabled: false, + disabledOptions: [1, 2], + }); down(); expect(isFocused(1)).toBe(true); }); }); describe('horizontal orientation', () => { - beforeEach(() => setupRadioGroup({orientation: 'horizontal'})); - 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(); @@ -491,26 +490,36 @@ describe('CdkRadioGroup', () => { }); it('should skip disabled radio buttons (skipDisabled="true")', () => { - setupRadioGroup({skipDisabled: true, disabledOptions: [1, 2]}); + 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({skipDisabled: false, disabledOptions: [1, 2]}); + setupRadioGroup({ + focusMode, + orientation: 'horizontal', + skipDisabled: false, + disabledOptions: [1, 2], + }); right(); expect(isFocused(1)).toBe(true); }); describe('text direction rtl', () => { - beforeEach(() => setupRadioGroup({textDirection: 'rtl', orientation: 'horizontal'})); - 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(); @@ -519,8 +528,11 @@ describe('CdkRadioGroup', () => { 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); @@ -530,30 +542,26 @@ describe('CdkRadioGroup', () => { }); describe(`pointer navigation (focusMode="${focusMode}")`, () => { - beforeEach(() => { - fixture = setupTestEnvironment(RadioGroupExample); - setupRadioGroup({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({skipDisabled: true, disabled: 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({skipDisabled: true, disabled: true}); + 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({readonly: true}); + setupRadioGroup({focusMode, readonly: true}); click(3); expect(isFocused(3)).toBe(true); }); @@ -569,10 +577,6 @@ describe('CdkRadioGroup', () => { }); describe('failure cases', () => { - beforeEach(() => { - fixture = setupTestEnvironment(RadioGroupExample); - }); - it('should handle an empty set of radio buttons gracefully', () => { setupRadioGroup({options: []}); expect(radioButtons.length).toBe(0); @@ -583,21 +587,21 @@ describe('CdkRadioGroup', () => { interface TestOption { value: number; label: string; - disabled: WritableSignal; + disabled: boolean; } @Component({ template: `
@for (option of options(); track option.value) { -
{{ option.label }}
+
{{ option.label }}
}
`, @@ -605,19 +609,19 @@ interface TestOption { }) class RadioGroupExample { options = signal([ - {value: 0, label: '0', disabled: signal(false)}, - {value: 1, label: '1', disabled: signal(false)}, - {value: 2, label: '2', disabled: signal(false)}, - {value: 3, label: '3', disabled: signal(false)}, - {value: 4, label: '4', disabled: signal(false)}, + {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}, ]); - disabled = signal(false); - readonly = signal(false); - value = signal(null); - skipDisabled = signal(true); - focusMode = signal<'roving' | 'activedescendant'>('roving'); - orientation = signal<'horizontal' | 'vertical'>('horizontal'); + value: number | null = null; + disabled = false; + readonly = false; + skipDisabled = true; + focusMode: 'roving' | 'activedescendant' = 'roving'; + orientation: 'horizontal' | 'vertical' = 'horizontal'; } @Component({ diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts index 5f15ecb7dc04..d1db9daf349d 100644 --- a/src/cdk-experimental/radio/radio.ts +++ b/src/cdk-experimental/radio/radio.ts @@ -18,11 +18,47 @@ import { 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) whose value is connected to the given original + * writable signal (signal) such that updating signal updates signal 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. * @@ -83,7 +119,10 @@ export class CdkRadioGroup { value = model(null); /** The internal selection state for the radio group. */ - private readonly _value = linkedSignal(() => (this.value() ? [this.value()!] : [])); + 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({ 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; From 76686708cd127d54e9723b1679c60f90d2717220 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 14 May 2025 16:23:13 -0400 Subject: [PATCH 10/10] fixup! feat(cdk-experimental/radio): create radio group and button directives --- src/cdk-experimental/radio/radio.spec.ts | 2 +- src/cdk-experimental/radio/radio.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cdk-experimental/radio/radio.spec.ts b/src/cdk-experimental/radio/radio.spec.ts index ca58af93655f..249ca625575d 100644 --- a/src/cdk-experimental/radio/radio.spec.ts +++ b/src/cdk-experimental/radio/radio.spec.ts @@ -1,4 +1,4 @@ -import {Component, DebugElement, signal, WritableSignal} from '@angular/core'; +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'; diff --git a/src/cdk-experimental/radio/radio.ts b/src/cdk-experimental/radio/radio.ts index d1db9daf349d..adb623261ef5 100644 --- a/src/cdk-experimental/radio/radio.ts +++ b/src/cdk-experimental/radio/radio.ts @@ -27,8 +27,8 @@ 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) whose value is connected to the given original - * writable signal (signal) such that updating signal updates signal and vice-versa. + * 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`