From 15e81c97a071c9117a15ca2c6d7bf049a3eb6287 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 2 May 2025 13:44:14 -0400 Subject: [PATCH 1/3] feat(cdk-experimental/radio): create radio group and button * Create new UI Patterns: RadioGroupPattern and RadioButtonPattern. * Create new experimental Cdk directives: CdkRadioGroup and CdkRadioButton. * Add a new demo for the new Cdk directives under /cdk-experimental-radio. --- .ng-dev/commit-message.mts | 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 | 173 ++++++++++++++ src/cdk-experimental/ui-patterns/BUILD.bazel | 1 + .../ui-patterns/public-api.ts | 2 + .../ui-patterns/radio/BUILD.bazel | 19 ++ .../ui-patterns/radio/radio-group.ts | 212 ++++++++++++++++++ .../ui-patterns/radio/radio.ts | 75 +++++++ .../cdk-experimental/radio/BUILD.bazel | 29 +++ .../radio/cdk-radio/cdk-radio-example.css | 86 +++++++ .../radio/cdk-radio/cdk-radio-example.html | 38 ++++ .../radio/cdk-radio/cdk-radio-example.ts | 32 +++ .../cdk-experimental/radio/index.ts | 1 + src/dev-app/BUILD.bazel | 1 + .../cdk-experimental-radio/BUILD.bazel | 21 ++ .../cdk-radio-demo.html | 4 + .../cdk-experimental-radio/cdk-radio-demo.ts | 17 ++ src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/routes.ts | 5 + 21 files changed, 753 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 create mode 100644 src/cdk-experimental/ui-patterns/radio/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/radio/radio-group.ts create mode 100644 src/cdk-experimental/ui-patterns/radio/radio.ts create mode 100644 src/components-examples/cdk-experimental/radio/BUILD.bazel create mode 100644 src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css create mode 100644 src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.html create mode 100644 src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.ts create mode 100644 src/components-examples/cdk-experimental/radio/index.ts create mode 100644 src/dev-app/cdk-experimental-radio/BUILD.bazel create mode 100644 src/dev-app/cdk-experimental-radio/cdk-radio-demo.html create mode 100644 src/dev-app/cdk-experimental-radio/cdk-radio-demo.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/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..063cefd4f7f9 --- /dev/null +++ b/src/cdk-experimental/radio/radio.ts @@ -0,0 +1,173 @@ +/** + * @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, + 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([]); // TODO: Change this to just be model(V|null). + + /** The current index that has been navigated to. */ + activeIndex = model(0); + + /** The RadioGroup UIPattern. */ + pattern: RadioGroupPattern = new RadioGroupPattern({ + ...this, + items: this.items, + 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, + }); +} diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index fee524d85d42..4b8299b571ee 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -12,6 +12,7 @@ ts_project( "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", "//src/cdk-experimental/ui-patterns/listbox", + "//src/cdk-experimental/ui-patterns/radio", "//src/cdk-experimental/ui-patterns/tabs", ], ) diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 9b11949ed77c..06383ea9b5bf 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -8,5 +8,7 @@ export * from './listbox/listbox'; export * from './listbox/option'; +export * from './radio/radio-group'; +export * from './radio/radio'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; diff --git a/src/cdk-experimental/ui-patterns/radio/BUILD.bazel b/src/cdk-experimental/ui-patterns/radio/BUILD.bazel new file mode 100644 index 000000000000..8efc2eaae9fe --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "radio", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) diff --git a/src/cdk-experimental/ui-patterns/radio/radio-group.ts b/src/cdk-experimental/ui-patterns/radio/radio-group.ts new file mode 100644 index 000000000000..5d1332165fe9 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio/radio-group.ts @@ -0,0 +1,212 @@ +/** + * @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 {computed, signal} 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'; +import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; +import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {RadioButtonPattern} from './radio'; + +/** The selection operations that the radio group can perform. */ +interface SelectOptions { + selectOne?: boolean; +} + +/** Represents the required inputs for a radio group. */ +export type RadioGroupInputs = ListNavigationInputs> & + // Radio groups are always single-select. + Omit, V>, 'multi' | 'selectionMode'> & + ListFocusInputs> & { + /** Whether the radio group is disabled. */ + disabled: SignalLike; + /** Whether the radio group is readonly. */ + readonly: SignalLike; + }; + +/** Controls the state of a radio group. */ +export class RadioGroupPattern { + /** Controls navigation for the radio group. */ + navigation: ListNavigation>; + + /** Controls selection for the radio group. */ + selection: ListSelection, V>; + + /** Controls focus for the radio group. */ + focusManager: ListFocus>; + + /** Whether the radio group is vertically or horizontally oriented. */ + orientation: SignalLike<'vertical' | 'horizontal'>; + + /** Whether the radio group is disabled. */ + disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled()); + + /** Whether the radio group is readonly. */ + readonly: SignalLike; + + /** The tabindex of the radio group (if using activedescendant). */ + tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active radio button (if using activedescendant). */ + activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** The key used to navigate to the previous radio button. */ + prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next radio button. */ + nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The keydown event manager for the radio group. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + // Readonly mode allows navigation but not selection changes. + if (this.readonly()) { + return manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()); + } + + // Default behavior: navigate and select on arrow keys, home, end. + // Space/Enter also select the focused item. + return manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(' ', () => this.selection.selectOne()) + .on('Enter', () => this.selection.selectOne()); + }); + + /** The pointerdown event manager for the radio group. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.readonly()) { + // Navigate focus only in readonly mode. + return manager.on(e => this.goto(e)); + } + + // Default behavior: navigate and select on click. + return manager.on(e => this.goto(e, {selectOne: true})); + }); + + constructor(readonly inputs: RadioGroupInputs) { + this.readonly = inputs.readonly; + this.orientation = inputs.orientation; + + this.focusManager = new ListFocus(inputs); + this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); + this.selection = new ListSelection({ + ...inputs, + // Radio groups are always single-select and selection follows focus. + multi: signal(false), + selectionMode: signal('follow'), + focusManager: this.focusManager, + }); + } + + /** Handles keydown events for the radio group. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the radio group. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first enabled radio button in the group. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.first()); + } + + /** Navigates to the last enabled radio button in the group. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.last()); + } + + /** Navigates to the next enabled radio button in the group. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.next()); + } + + /** Navigates to the previous enabled radio button in the group. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.prev()); + } + + /** Navigates to the radio button associated with the given pointer event. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + this._navigate(opts, () => this.navigation.goto(item)); + } + + /** + * Sets the radio group to its default initial state. + * + * Sets the active index to the selected radio button if one exists and is focusable. + * Otherwise, sets the active index to the first focusable radio button. + */ + setDefaultState() { + let firstItem: RadioButtonPattern | null = null; + + for (const item of this.inputs.items()) { + if (this.focusManager.isFocusable(item)) { + if (!firstItem) { + firstItem = item; + } + if (item.selected()) { + this.inputs.activeIndex.set(item.index()); + return; + } + } + } + + if (firstItem) { + this.inputs.activeIndex.set(firstItem.index()); + } + } + + /** Safely performs a navigation operation and updates selection if needed. */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + const moved = operation(); + if (moved && opts.selectOne) { + this.selection.selectOne(); + } + } + + /** Finds the RadioButtonPattern associated with a pointer event target. */ + private _getItem(e: PointerEvent): RadioButtonPattern | undefined { + if (!(e.target instanceof HTMLElement)) { + return undefined; + } + + // Assumes the target or its ancestor has role="radio" + const element = e.target.closest('[role="radio"]'); + return this.inputs.items().find(i => i.element() === element); + } +} diff --git a/src/cdk-experimental/ui-patterns/radio/radio.ts b/src/cdk-experimental/ui-patterns/radio/radio.ts new file mode 100644 index 000000000000..f5eafadcf515 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio/radio.ts @@ -0,0 +1,75 @@ +/** + * @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 {computed} from '@angular/core'; +import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection'; +import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** + * Represents the properties exposed by a radio group that need to be accessed by a radio button. + * This exists to avoid circular dependency errors between the radio group and radio button. + */ +interface RadioGroupLike { + focusManager: ListFocus>; + selection: ListSelection, V>; + navigation: ListNavigation>; +} + +/** Represents the required inputs for a radio button in a radio group. */ +export interface RadioButtonInputs + extends ListNavigationItem, + ListSelectionItem, + ListFocusItem { + /** A reference to the parent radio group. */ + group: SignalLike | undefined>; +} + +/** Represents a radio button within a radio group. */ +export class RadioButtonPattern { + /** A unique identifier for the radio button. */ + id: SignalLike; + + /** The value associated with the radio button. */ + value: SignalLike; + + /** The position of the radio button within the group. */ + index = computed( + () => + this.group() + ?.navigation.inputs.items() + .findIndex(i => i.id() === this.id()) ?? -1, + ); + + /** Whether the radio button is currently the active one (focused). */ + active = computed(() => this.group()?.focusManager.activeItem() === this); + + /** Whether the radio button is selected. */ + selected = computed(() => this.group()?.selection.inputs.value().includes(this.value())); + + /** Whether the radio button is disabled. */ + disabled: SignalLike; + + /** A reference to the parent radio group. */ + group: SignalLike | undefined>; + + /** The tabindex of the radio button. */ + tabindex = computed(() => this.group()?.focusManager.getItemTabindex(this)); + + /** The HTML element associated with the radio button. */ + element: SignalLike; + + constructor(inputs: RadioButtonInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.group = inputs.group; + this.element = inputs.element; + this.disabled = inputs.disabled; + } +} diff --git a/src/components-examples/cdk-experimental/radio/BUILD.bazel b/src/components-examples/cdk-experimental/radio/BUILD.bazel new file mode 100644 index 000000000000..f33b43cf96d0 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "radio", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/cdk-experimental/radio", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css new file mode 100644 index 000000000000..2ede95985726 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css @@ -0,0 +1,86 @@ +.example-radio-controls { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 16px; +} + +.example-radio-group { + gap: 4px; + margin: 0; + padding: 8px; + max-height: 50vh; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + list-style: none; + flex-direction: column; + overflow: scroll; + user-select: none; +} + +.example-radio-group[aria-orientation='horizontal'] { + flex-direction: row; +} + +.example-radio-group[aria-disabled='true'] { + background-color: var(--mat-sys-surface-dim); + pointer-events: none; +} + +.example-radio-button { + gap: 16px; + padding: 16px; + display: flex; + cursor: pointer; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +/* Basic visual indicator for the radio button */ +.example-radio-indicator { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--mat-sys-outline); + display: inline-block; + position: relative; +} + +.example-radio-button[aria-checked='true'] .example-radio-indicator::after { + content: ''; + display: block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--mat-sys-primary); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.example-radio-button.cdk-active, +.example-radio-button[aria-disabled='false']:hover { + outline: 1px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-container); +} + +.example-radio-button[aria-disabled='false']:focus-within { + outline: 2px solid var(--mat-sys-primary); + background: var(--mat-sys-surface-container); +} + +.example-radio-button.cdk-active[aria-disabled='true'], +.example-radio-button[aria-disabled='true']:focus-within { + outline: 2px solid var(--mat-sys-outline); +} + +.example-radio-button[aria-disabled='true'] { + cursor: default; +} + +.example-radio-button[aria-disabled='true'] span:not(.example-radio-indicator) { + opacity: 0.3; +} \ No newline at end of file diff --git a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.html b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.html new file mode 100644 index 000000000000..abdf7197c620 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.html @@ -0,0 +1,38 @@ +
+ Disabled + + + Disabled Options + + @for (flavor of flavors; track flavor) { + {{flavor}} + } + + + + + Orientation + + Vertical + Horizontal + + +
+ + +
    + @for (flavor of flavors; track flavor) { + @let optionDisabled = disabledOptions.includes(flavor); +
  • + + {{ flavor }} +
  • + } +
+ \ No newline at end of file diff --git a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.ts b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.ts new file mode 100644 index 000000000000..6ede1fc0c5a1 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.ts @@ -0,0 +1,32 @@ +import {Component} from '@angular/core'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; + +/** @title Basic CDK Radio Group */ +@Component({ + selector: 'cdk-radio-example', + exportAs: 'cdkRadioExample', + templateUrl: 'cdk-radio-example.html', + styleUrl: 'cdk-radio-example.css', + standalone: true, + imports: [ + CdkRadioGroup, + CdkRadioButton, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + FormsModule, + ReactiveFormsModule, + ], +}) +export class CdkRadioExample { + orientation: 'vertical' | 'horizontal' = 'vertical'; + disabledOptions: string[] = ['Chocolate']; + + disabled = new FormControl(false, {nonNullable: true}); + + flavors = ['Vanilla', 'Chocolate', 'Strawberry', 'Mint Chip', 'Cookies & Cream', 'Rocky Road']; +} diff --git a/src/components-examples/cdk-experimental/radio/index.ts b/src/components-examples/cdk-experimental/radio/index.ts new file mode 100644 index 000000000000..722738a66748 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio/index.ts @@ -0,0 +1 @@ +export {CdkRadioExample} from './cdk-radio/cdk-radio-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 8073332cd43f..a4502d16d077 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -35,6 +35,7 @@ ng_project( "//src/dev-app/cdk-dialog", "//src/dev-app/cdk-experimental-combobox", "//src/dev-app/cdk-experimental-listbox", + "//src/dev-app/cdk-experimental-radio", "//src/dev-app/cdk-experimental-tabs", "//src/dev-app/cdk-listbox", "//src/dev-app/cdk-menu", diff --git a/src/dev-app/cdk-experimental-radio/BUILD.bazel b/src/dev-app/cdk-experimental-radio/BUILD.bazel new file mode 100644 index 000000000000..bbd2644b2cf5 --- /dev/null +++ b/src/dev-app/cdk-experimental-radio/BUILD.bazel @@ -0,0 +1,21 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-radio", + srcs = glob(["**/*.ts"]), + assets = ["cdk-radio-demo.html"], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/cdk-experimental/radio", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.ts", + "**/*.html", + ]), +) diff --git a/src/dev-app/cdk-experimental-radio/cdk-radio-demo.html b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.html new file mode 100644 index 000000000000..e50dbbf549fc --- /dev/null +++ b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.html @@ -0,0 +1,4 @@ +
+

Radio using UI Patterns

+ +
\ No newline at end of file diff --git a/src/dev-app/cdk-experimental-radio/cdk-radio-demo.ts b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.ts new file mode 100644 index 000000000000..e170b556d703 --- /dev/null +++ b/src/dev-app/cdk-experimental-radio/cdk-radio-demo.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {CdkRadioExample} from '@angular/components-examples/cdk-experimental/radio'; + +@Component({ + templateUrl: 'cdk-radio-demo.html', + imports: [CdkRadioExample], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalRadioDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 12a9f56de8f0..a2dd619b2b1f 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -60,6 +60,7 @@ export class DevAppLayout { {name: 'Examples', route: '/examples'}, {name: 'CDK Dialog', route: '/cdk-dialog'}, {name: 'CDK Experimental Combobox', route: '/cdk-experimental-combobox'}, + {name: 'CDK Experimental Radio', route: '/cdk-experimental-radio'}, {name: 'CDK Experimental Listbox', route: '/cdk-experimental-listbox'}, {name: 'CDK Experimental Tabs', route: '/cdk-experimental-tabs'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index bc01db461312..2ad7545fb3fa 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -50,6 +50,11 @@ export const DEV_APP_ROUTES: Routes = [ loadComponent: () => import('./cdk-experimental-listbox/cdk-listbox-demo').then(m => m.CdkExperimentalListboxDemo), }, + { + path: 'cdk-experimental-radio', + loadComponent: () => + import('./cdk-experimental-radio/cdk-radio-demo').then(m => m.CdkExperimentalRadioDemo), + }, { path: 'cdk-experimental-tabs', loadComponent: () => From dbec126a7fbbd497207665be8db4ce5bae23b6dc Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 2 May 2025 13:49:01 -0400 Subject: [PATCH 2/3] fixup! feat(cdk-experimental/radio): create radio group and button --- .../cdk-experimental/radio/cdk-radio/cdk-radio-example.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css index 2ede95985726..37f40b58c271 100644 --- a/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css +++ b/src/components-examples/cdk-experimental/radio/cdk-radio/cdk-radio-example.css @@ -83,4 +83,4 @@ .example-radio-button[aria-disabled='true'] span:not(.example-radio-indicator) { opacity: 0.3; -} \ No newline at end of file +} From 8dbbee869f480b50b909ef299025b4167c89b16d Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 2 May 2025 15:23:28 -0400 Subject: [PATCH 3/3] fixup! feat(cdk-experimental/radio): create radio group and button --- src/cdk-experimental/config.bzl | 1 + 1 file changed, 1 insertion(+) 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",