/** * @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 {_IdGenerator, FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; import { ANIMATION_MODULE_TYPE, AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, Directive, DoCheck, ElementRef, EventEmitter, InjectionToken, Injector, Input, NgZone, OnDestroy, OnInit, Output, QueryList, ViewChild, ViewEncapsulation, afterNextRender, booleanAttribute, forwardRef, inject, numberAttribute, HostAttributeToken, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import { MatRipple, ThemePalette, _MatInternalFormField, _StructuralStylesLoader, } from '@angular/material/core'; import {Subscription} from 'rxjs'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; /** Change event object emitted by radio button and radio group. */ export class MatRadioChange { constructor( /** The radio button that emits the change event. */ public source: MatRadioButton, /** The value of the radio button. */ public value: any, ) {} } /** * Provider Expression that allows mat-radio-group to register as a ControlValueAccessor. This * allows it to support [(ngModel)] and ngControl. * @docs-private */ export const MAT_RADIO_GROUP_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MatRadioGroup), multi: true, }; /** * Injection token that can be used to inject instances of `MatRadioGroup`. It serves as * alternative token to the actual `MatRadioGroup` class which could cause unnecessary * retention of the class and its component metadata. */ export const MAT_RADIO_GROUP = new InjectionToken('MatRadioGroup'); export interface MatRadioDefaultOptions { /** * Theme color of the radio button. This API is supported in M2 themes only, it * has no effect in M3 themes. * * For information on applying color variants in M3, see * https://material.angular.io/guide/theming#using-component-color-variants. */ color: ThemePalette; /** Whether disabled radio buttons should be interactive. */ disabledInteractive?: boolean; } export const MAT_RADIO_DEFAULT_OPTIONS = new InjectionToken( 'mat-radio-default-options', { providedIn: 'root', factory: MAT_RADIO_DEFAULT_OPTIONS_FACTORY, }, ); export function MAT_RADIO_DEFAULT_OPTIONS_FACTORY(): MatRadioDefaultOptions { return { color: 'accent', disabledInteractive: false, }; } /** * A group of radio buttons. May contain one or more `` elements. */ @Directive({ selector: 'mat-radio-group', exportAs: 'matRadioGroup', providers: [ MAT_RADIO_GROUP_CONTROL_VALUE_ACCESSOR, {provide: MAT_RADIO_GROUP, useExisting: MatRadioGroup}, ], host: { 'role': 'radiogroup', 'class': 'mat-mdc-radio-group', }, }) export class MatRadioGroup implements AfterContentInit, OnDestroy, ControlValueAccessor { private _changeDetector = inject(ChangeDetectorRef); /** Selected value for the radio group. */ private _value: any = null; /** The HTML name attribute applied to radio buttons in this group. */ private _name: string = inject(_IdGenerator).getId('mat-radio-group-'); /** The currently selected radio button. Should match value. */ private _selected: MatRadioButton | null = null; /** Whether the `value` has been set to its initial value. */ private _isInitialized: boolean = false; /** Whether the labels should appear after or before the radio-buttons. Defaults to 'after' */ private _labelPosition: 'before' | 'after' = 'after'; /** Whether the radio group is disabled. */ private _disabled: boolean = false; /** Whether the radio group is required. */ private _required: boolean = false; /** Subscription to changes in amount of radio buttons. */ private _buttonChanges: Subscription; /** The method to be called in order to update ngModel */ _controlValueAccessorChangeFn: (value: any) => void = () => {}; /** * onTouch function registered via registerOnTouch (ControlValueAccessor). * @docs-private */ onTouched: () => any = () => {}; /** * Event emitted when the group value changes. * Change events are only emitted when the value changes due to user interaction with * a radio button (the same behavior as ``). */ @Output() readonly change: EventEmitter = new EventEmitter(); /** Child radio buttons. */ @ContentChildren(forwardRef(() => MatRadioButton), {descendants: true}) _radios: QueryList; /** * Theme color of the radio buttons in the group. This API is supported in M2 * themes only, it has no effect in M3 themes. * * For information on applying color variants in M3, see * https://material.angular.io/guide/theming#using-component-color-variants. */ @Input() color: ThemePalette; /** Name of the radio button group. All radio buttons inside this group will use this name. */ @Input() get name(): string { return this._name; } set name(value: string) { this._name = value; this._updateRadioButtonNames(); } /** Whether the labels should appear after or before the radio-buttons. Defaults to 'after' */ @Input() get labelPosition(): 'before' | 'after' { return this._labelPosition; } set labelPosition(v) { this._labelPosition = v === 'before' ? 'before' : 'after'; this._markRadiosForCheck(); } /** * Value for the radio-group. Should equal the value of the selected radio button if there is * a corresponding radio button with a matching value. If there is not such a corresponding * radio button, this value persists to be applied in case a new radio button is added with a * matching value. */ @Input() get value(): any { return this._value; } set value(newValue: any) { if (this._value !== newValue) { // Set this before proceeding to ensure no circular loop occurs with selection. this._value = newValue; this._updateSelectedRadioFromValue(); this._checkSelectedRadioButton(); } } _checkSelectedRadioButton() { if (this._selected && !this._selected.checked) { this._selected.checked = true; } } /** * The currently selected radio button. If set to a new radio button, the radio group value * will be updated to match the new selected button. */ @Input() get selected() { return this._selected; } set selected(selected: MatRadioButton | null) { this._selected = selected; this.value = selected ? selected.value : null; this._checkSelectedRadioButton(); } /** Whether the radio group is disabled */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled; } set disabled(value: boolean) { this._disabled = value; this._markRadiosForCheck(); } /** Whether the radio group is required */ @Input({transform: booleanAttribute}) get required(): boolean { return this._required; } set required(value: boolean) { this._required = value; this._markRadiosForCheck(); } /** Whether buttons in the group should be interactive while they're disabled. */ @Input({transform: booleanAttribute}) get disabledInteractive(): boolean { return this._disabledInteractive; } set disabledInteractive(value: boolean) { this._disabledInteractive = value; this._markRadiosForCheck(); } private _disabledInteractive = false; constructor(...args: unknown[]); constructor() {} /** * Initialize properties once content children are available. * This allows us to propagate relevant attributes to associated buttons. */ ngAfterContentInit() { // Mark this component as initialized in AfterContentInit because the initial value can // possibly be set by NgModel on MatRadioGroup, and it is possible that the OnInit of the // NgModel occurs *after* the OnInit of the MatRadioGroup. this._isInitialized = true; // Clear the `selected` button when it's destroyed since the tabindex of the rest of the // buttons depends on it. Note that we don't clear the `value`, because the radio button // may be swapped out with a similar one and there are some internal apps that depend on // that behavior. this._buttonChanges = this._radios.changes.subscribe(() => { if (this.selected && !this._radios.find(radio => radio === this.selected)) { this._selected = null; } }); } ngOnDestroy() { this._buttonChanges?.unsubscribe(); } /** * Mark this group as being "touched" (for ngModel). Meant to be called by the contained * radio buttons upon their blur. */ _touch() { if (this.onTouched) { this.onTouched(); } } private _updateRadioButtonNames(): void { if (this._radios) { this._radios.forEach(radio => { radio.name = this.name; radio._markForCheck(); }); } } /** Updates the `selected` radio button from the internal _value state. */ private _updateSelectedRadioFromValue(): void { // If the value already matches the selected radio, do nothing. const isAlreadySelected = this._selected !== null && this._selected.value === this._value; if (this._radios && !isAlreadySelected) { this._selected = null; this._radios.forEach(radio => { radio.checked = this.value === radio.value; if (radio.checked) { this._selected = radio; } }); } } /** Dispatch change event with current selection and group value. */ _emitChangeEvent(): void { if (this._isInitialized) { this.change.emit(new MatRadioChange(this._selected!, this._value)); } } _markRadiosForCheck() { if (this._radios) { this._radios.forEach(radio => radio._markForCheck()); } } /** * Sets the model value. Implemented as part of ControlValueAccessor. * @param value */ writeValue(value: any) { this.value = value; this._changeDetector.markForCheck(); } /** * Registers a callback to be triggered when the model value changes. * Implemented as part of ControlValueAccessor. * @param fn Callback to be registered. */ registerOnChange(fn: (value: any) => void) { this._controlValueAccessorChangeFn = fn; } /** * Registers a callback to be triggered when the control is touched. * Implemented as part of ControlValueAccessor. * @param fn Callback to be registered. */ registerOnTouched(fn: any) { this.onTouched = fn; } /** * Sets the disabled state of the control. Implemented as a part of ControlValueAccessor. * @param isDisabled Whether the control should be disabled. */ setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; this._changeDetector.markForCheck(); } } @Component({ selector: 'mat-radio-button', templateUrl: 'radio.html', styleUrl: 'radio.css', host: { 'class': 'mat-mdc-radio-button', '[attr.id]': 'id', '[class.mat-primary]': 'color === "primary"', '[class.mat-accent]': 'color === "accent"', '[class.mat-warn]': 'color === "warn"', '[class.mat-mdc-radio-checked]': 'checked', '[class.mat-mdc-radio-disabled]': 'disabled', '[class.mat-mdc-radio-disabled-interactive]': 'disabledInteractive', '[class._mat-animation-noopable]': '_noopAnimations', // Needs to be removed since it causes some a11y issues (see #21266). '[attr.tabindex]': 'null', '[attr.aria-label]': 'null', '[attr.aria-labelledby]': 'null', '[attr.aria-describedby]': 'null', // Note: under normal conditions focus shouldn't land on this element, however it may be // programmatically set, for example inside of a focus trap, in this case we want to forward // the focus to the native element. '(focus)': '_inputElement.nativeElement.focus()', }, exportAs: 'matRadioButton', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatRipple, _MatInternalFormField], }) export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy { protected _elementRef = inject(ElementRef); private _changeDetector = inject(ChangeDetectorRef); private _focusMonitor = inject(FocusMonitor); private _radioDispatcher = inject(UniqueSelectionDispatcher); private _defaultOptions = inject(MAT_RADIO_DEFAULT_OPTIONS, { optional: true, }); private _ngZone = inject(NgZone); private _uniqueId: string = inject(_IdGenerator).getId('mat-radio-'); /** The unique ID for the radio button. */ @Input() id: string = this._uniqueId; /** Analog to HTML 'name' attribute used to group radios for unique selection. */ @Input() name: string; /** Used to set the 'aria-label' attribute on the underlying input element. */ @Input('aria-label') ariaLabel: string; /** The 'aria-labelledby' attribute takes precedence as the element's text alternative. */ @Input('aria-labelledby') ariaLabelledby: string; /** The 'aria-describedby' attribute is read after the element's label and field type. */ @Input('aria-describedby') ariaDescribedby: string; /** Whether ripples are disabled inside the radio button */ @Input({transform: booleanAttribute}) disableRipple: boolean = false; /** Tabindex of the radio button. */ @Input({ transform: (value: unknown) => (value == null ? 0 : numberAttribute(value)), }) tabIndex: number = 0; /** Whether this radio button is checked. */ @Input({transform: booleanAttribute}) get checked(): boolean { return this._checked; } set checked(value: boolean) { if (this._checked !== value) { this._checked = value; if (value && this.radioGroup && this.radioGroup.value !== this.value) { this.radioGroup.selected = this; } else if (!value && this.radioGroup && this.radioGroup.value === this.value) { // When unchecking the selected radio button, update the selected radio // property on the group. this.radioGroup.selected = null; } if (value) { // Notify all radio buttons with the same name to un-check. this._radioDispatcher.notify(this.id, this.name); } this._changeDetector.markForCheck(); } } /** The value of this radio button. */ @Input() get value(): any { return this._value; } set value(value: any) { if (this._value !== value) { this._value = value; if (this.radioGroup !== null) { if (!this.checked) { // Update checked when the value changed to match the radio group's value this.checked = this.radioGroup.value === value; } if (this.checked) { this.radioGroup.selected = this; } } } } /** Whether the label should appear after or before the radio button. Defaults to 'after' */ @Input() get labelPosition(): 'before' | 'after' { return this._labelPosition || (this.radioGroup && this.radioGroup.labelPosition) || 'after'; } set labelPosition(value) { this._labelPosition = value; } private _labelPosition: 'before' | 'after'; /** Whether the radio button is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled || (this.radioGroup !== null && this.radioGroup.disabled); } set disabled(value: boolean) { this._setDisabled(value); } /** Whether the radio button is required. */ @Input({transform: booleanAttribute}) get required(): boolean { return this._required || (this.radioGroup && this.radioGroup.required); } set required(value: boolean) { this._required = value; } /** * Theme color of the radio button. This API is supported in M2 themes only, it * has no effect in M3 themes. * * For information on applying color variants in M3, see * https://material.angular.io/guide/theming#using-component-color-variants. */ @Input() get color(): ThemePalette { // As per M2 design specifications the selection control radio should use the accent color // palette by default. https://m2.material.io/components/radio-buttons#specs return ( this._color || (this.radioGroup && this.radioGroup.color) || (this._defaultOptions && this._defaultOptions.color) || 'accent' ); } set color(newValue: ThemePalette) { this._color = newValue; } private _color: ThemePalette; /** Whether the radio button should remain interactive when it is disabled. */ @Input({transform: booleanAttribute}) get disabledInteractive(): boolean { return ( this._disabledInteractive || (this.radioGroup !== null && this.radioGroup.disabledInteractive) ); } set disabledInteractive(value: boolean) { this._disabledInteractive = value; } private _disabledInteractive: boolean; /** * Event emitted when the checked state of this radio button changes. * Change events are only emitted when the value changes due to user interaction with * the radio button (the same behavior as ``). */ @Output() readonly change: EventEmitter = new EventEmitter(); /** The parent radio group. May or may not be present. */ radioGroup: MatRadioGroup; /** ID of the native input element inside `` */ get inputId(): string { return `${this.id || this._uniqueId}-input`; } /** Whether this radio is checked. */ private _checked: boolean = false; /** Whether this radio is disabled. */ private _disabled: boolean; /** Whether this radio is required. */ private _required: boolean; /** Value assigned to this radio. */ private _value: any = null; /** Unregister function for _radioDispatcher */ private _removeUniqueSelectionListener: () => void = () => {}; /** Previous value of the input's tabindex. */ private _previousTabIndex: number | undefined; /** The native `` element */ @ViewChild('input') _inputElement: ElementRef; /** Trigger elements for the ripple events. */ @ViewChild('formField', {read: ElementRef, static: true}) _rippleTrigger: ElementRef; /** Whether animations are disabled. */ _noopAnimations: boolean; private _injector = inject(Injector); constructor(...args: unknown[]); constructor() { inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader); const radioGroup = inject(MAT_RADIO_GROUP, {optional: true})!; const animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true}); const tabIndex = inject(new HostAttributeToken('tabindex'), {optional: true}); // Assertions. Ideally these should be stripped out by the compiler. // TODO(jelbourn): Assert that there's no name binding AND a parent radio group. this.radioGroup = radioGroup; this._noopAnimations = animationMode === 'NoopAnimations'; this._disabledInteractive = this._defaultOptions?.disabledInteractive ?? false; if (tabIndex) { this.tabIndex = numberAttribute(tabIndex, 0); } } /** Focuses the radio button. */ focus(options?: FocusOptions, origin?: FocusOrigin): void { if (origin) { this._focusMonitor.focusVia(this._inputElement, origin, options); } else { this._inputElement.nativeElement.focus(options); } } /** * Marks the radio button as needing checking for change detection. * This method is exposed because the parent radio group will directly * update bound properties of the radio button. */ _markForCheck() { // When group value changes, the button will not be notified. Use `markForCheck` to explicit // update radio button's status this._changeDetector.markForCheck(); } ngOnInit() { if (this.radioGroup) { // If the radio is inside a radio group, determine if it should be checked this.checked = this.radioGroup.value === this._value; if (this.checked) { this.radioGroup.selected = this; } // Copy name from parent radio group this.name = this.radioGroup.name; } this._removeUniqueSelectionListener = this._radioDispatcher.listen((id, name) => { if (id !== this.id && name === this.name) { this.checked = false; } }); } ngDoCheck(): void { this._updateTabIndex(); } ngAfterViewInit() { this._updateTabIndex(); this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => { if (!focusOrigin && this.radioGroup) { this.radioGroup._touch(); } }); // We bind this outside of the zone, because: // 1. Its logic is completely DOM-related so we can avoid some change detections. // 2. There appear to be some internal tests that break when this triggers a change detection. this._ngZone.runOutsideAngular(() => { this._inputElement.nativeElement.addEventListener('click', this._onInputClick); }); } ngOnDestroy() { // We need to null check in case the button was destroyed before `ngAfterViewInit`. this._inputElement?.nativeElement.removeEventListener('click', this._onInputClick); this._focusMonitor.stopMonitoring(this._elementRef); this._removeUniqueSelectionListener(); } /** Dispatch change event with current value. */ private _emitChangeEvent(): void { this.change.emit(new MatRadioChange(this, this._value)); } _isRippleDisabled() { return this.disableRipple || this.disabled; } /** Triggered when the radio button receives an interaction from the user. */ _onInputInteraction(event: Event) { // We always have to stop propagation on the change event. // Otherwise the change event, from the input element, will bubble up and // emit its event object to the `change` output. event.stopPropagation(); if (!this.checked && !this.disabled) { const groupValueChanged = this.radioGroup && this.value !== this.radioGroup.value; this.checked = true; this._emitChangeEvent(); if (this.radioGroup) { this.radioGroup._controlValueAccessorChangeFn(this.value); if (groupValueChanged) { this.radioGroup._emitChangeEvent(); } } } } /** Triggered when the user clicks on the touch target. */ _onTouchTargetClick(event: Event) { this._onInputInteraction(event); if (!this.disabled || this.disabledInteractive) { // Normally the input should be focused already, but if the click // comes from the touch target, then we might have to focus it ourselves. this._inputElement?.nativeElement.focus(); } } /** Sets the disabled state and marks for check if a change occurred. */ protected _setDisabled(value: boolean) { if (this._disabled !== value) { this._disabled = value; this._changeDetector.markForCheck(); } } /** Called when the input is clicked. */ private _onInputClick = (event: Event) => { // If the input is disabled while interactive, we need to prevent the // selection from happening in this event handler. Note that even though // this happens on `click` events, the logic applies when the user is // navigating with the keyboard as well. An alternative way of doing // this is by resetting the `checked` state in the `change` callback but // it isn't optimal, because it can allow a pre-checked disabled button // to be un-checked. This approach seems to cover everything. if (this.disabled && this.disabledInteractive) { event.preventDefault(); } }; /** Gets the tabindex for the underlying input element. */ private _updateTabIndex() { const group = this.radioGroup; let value: number; // Implement a roving tabindex if the button is inside a group. For most cases this isn't // necessary, because the browser handles the tab order for inputs inside a group automatically, // but we need an explicitly higher tabindex for the selected button in order for things like // the focus trap to pick it up correctly. if (!group || !group.selected || this.disabled) { value = this.tabIndex; } else { value = group.selected === this ? this.tabIndex : -1; } if (value !== this._previousTabIndex) { // We have to set the tabindex directly on the DOM node, because it depends on // the selected state which is prone to "changed after checked errors". const input: HTMLInputElement | undefined = this._inputElement?.nativeElement; if (input) { input.setAttribute('tabindex', value + ''); this._previousTabIndex = value; // Wait for any pending tabindex changes to be applied afterNextRender( () => { queueMicrotask(() => { // The radio group uses a "selection follows focus" pattern for tab management, so if this // radio button is currently focused and another radio button in the group becomes // selected, we should move focus to the newly selected radio button to maintain // consistency between the focused and selected states. if ( group && group.selected && group.selected !== this && document.activeElement === input ) { group.selected?._inputElement.nativeElement.focus(); // If this radio button still has focus, the selected one must be disabled. In this // case the radio group as a whole should lose focus. if (document.activeElement === input) { this._inputElement.nativeElement.blur(); } } }); }, {injector: this._injector}, ); } } } }