/** * @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} from '@angular/cdk/a11y'; import {SelectionModel} from '@angular/cdk/collections'; import {DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW, SPACE, ENTER} from '@angular/cdk/keycodes'; import { AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, Directive, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, OnInit, Output, QueryList, ViewChild, ViewEncapsulation, InjectionToken, AfterViewInit, booleanAttribute, inject, HostAttributeToken, ANIMATION_MODULE_TYPE, } from '@angular/core'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {MatRipple, MatPseudoCheckbox, _StructuralStylesLoader} from '@angular/material/core'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; /** * @deprecated No longer used. * @breaking-change 11.0.0 */ export type ToggleType = 'checkbox' | 'radio'; /** Possible appearance styles for the button toggle. */ export type MatButtonToggleAppearance = 'legacy' | 'standard'; /** * Represents the default options for the button toggle that can be configured * using the `MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS` injection token. */ export interface MatButtonToggleDefaultOptions { /** * Default appearance to be used by button toggles. Can be overridden by explicitly * setting an appearance on a button toggle or group. */ appearance?: MatButtonToggleAppearance; /** Whether icon indicators should be hidden for single-selection button toggle groups. */ hideSingleSelectionIndicator?: boolean; /** Whether icon indicators should be hidden for multiple-selection button toggle groups. */ hideMultipleSelectionIndicator?: boolean; /** Whether disabled toggle buttons should be interactive. */ disabledInteractive?: boolean; } /** * Injection token that can be used to configure the * default options for all button toggles within an app. */ export const MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS = new InjectionToken( 'MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS', { providedIn: 'root', factory: MAT_BUTTON_TOGGLE_GROUP_DEFAULT_OPTIONS_FACTORY, }, ); export function MAT_BUTTON_TOGGLE_GROUP_DEFAULT_OPTIONS_FACTORY(): MatButtonToggleDefaultOptions { return { hideSingleSelectionIndicator: false, hideMultipleSelectionIndicator: false, disabledInteractive: false, }; } /** * Injection token that can be used to reference instances of `MatButtonToggleGroup`. * It serves as alternative token to the actual `MatButtonToggleGroup` class which * could cause unnecessary retention of the class and its component metadata. */ export const MAT_BUTTON_TOGGLE_GROUP = new InjectionToken( 'MatButtonToggleGroup', ); /** * Provider Expression that allows mat-button-toggle-group to register as a ControlValueAccessor. * This allows it to support [(ngModel)]. * @docs-private */ export const MAT_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MatButtonToggleGroup), multi: true, }; /** Change event object emitted by button toggle. */ export class MatButtonToggleChange { constructor( /** The button toggle that emits the event. */ public source: MatButtonToggle, /** The value assigned to the button toggle. */ public value: any, ) {} } /** Exclusive selection button toggle group that behaves like a radio-button group. */ @Directive({ selector: 'mat-button-toggle-group', providers: [ MAT_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR, {provide: MAT_BUTTON_TOGGLE_GROUP, useExisting: MatButtonToggleGroup}, ], host: { 'class': 'mat-button-toggle-group', '(keydown)': '_keydown($event)', '[attr.role]': "multiple ? 'group' : 'radiogroup'", '[attr.aria-disabled]': 'disabled', '[class.mat-button-toggle-vertical]': 'vertical', '[class.mat-button-toggle-group-appearance-standard]': 'appearance === "standard"', }, exportAs: 'matButtonToggleGroup', }) export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit { private _changeDetector = inject(ChangeDetectorRef); private _dir = inject(Directionality, {optional: true}); private _multiple = false; private _disabled = false; private _disabledInteractive = false; private _selectionModel: SelectionModel; /** * Reference to the raw value that the consumer tried to assign. The real * value will exclude any values from this one that don't correspond to a * toggle. Useful for the cases where the value is assigned before the toggles * have been initialized or at the same that they're being swapped out. */ private _rawValue: any; /** * The method to be called in order to update ngModel. * Now `ngModel` binding is not supported in multiple selection mode. */ _controlValueAccessorChangeFn: (value: any) => void = () => {}; /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ _onTouched: () => any = () => {}; /** Child button toggle buttons. */ @ContentChildren(forwardRef(() => MatButtonToggle), { // Note that this would technically pick up toggles // from nested groups, but that's not a case that we support. descendants: true, }) _buttonToggles: QueryList; /** The appearance for all the buttons in the group. */ @Input() appearance: MatButtonToggleAppearance; /** `name` attribute for the underlying `input` element. */ @Input() get name(): string { return this._name; } set name(value: string) { this._name = value; this._markButtonsForCheck(); } private _name = inject(_IdGenerator).getId('mat-button-toggle-group-'); /** Whether the toggle group is vertical. */ @Input({transform: booleanAttribute}) vertical: boolean; /** Value of the toggle group. */ @Input() get value(): any { const selected = this._selectionModel ? this._selectionModel.selected : []; if (this.multiple) { return selected.map(toggle => toggle.value); } return selected[0] ? selected[0].value : undefined; } set value(newValue: any) { this._setSelectionByValue(newValue); this.valueChange.emit(this.value); } /** * Event that emits whenever the value of the group changes. * Used to facilitate two-way data binding. * @docs-private */ @Output() readonly valueChange = new EventEmitter(); /** Selected button toggles in the group. */ get selected(): MatButtonToggle | MatButtonToggle[] { const selected = this._selectionModel ? this._selectionModel.selected : []; return this.multiple ? selected : selected[0] || null; } /** Whether multiple button toggles can be selected. */ @Input({transform: booleanAttribute}) get multiple(): boolean { return this._multiple; } set multiple(value: boolean) { this._multiple = value; this._markButtonsForCheck(); } /** Whether multiple button toggle group is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled; } set disabled(value: boolean) { this._disabled = value; this._markButtonsForCheck(); } /** 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._markButtonsForCheck(); } /** The layout direction of the toggle button group. */ get dir(): Direction { return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr'; } /** Event emitted when the group's value changes. */ @Output() readonly change: EventEmitter = new EventEmitter(); /** Whether checkmark indicator for single-selection button toggle groups is hidden. */ @Input({transform: booleanAttribute}) get hideSingleSelectionIndicator(): boolean { return this._hideSingleSelectionIndicator; } set hideSingleSelectionIndicator(value: boolean) { this._hideSingleSelectionIndicator = value; this._markButtonsForCheck(); } private _hideSingleSelectionIndicator: boolean; /** Whether checkmark indicator for multiple-selection button toggle groups is hidden. */ @Input({transform: booleanAttribute}) get hideMultipleSelectionIndicator(): boolean { return this._hideMultipleSelectionIndicator; } set hideMultipleSelectionIndicator(value: boolean) { this._hideMultipleSelectionIndicator = value; this._markButtonsForCheck(); } private _hideMultipleSelectionIndicator: boolean; constructor(...args: unknown[]); constructor() { const defaultOptions = inject( MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS, {optional: true}, ); this.appearance = defaultOptions && defaultOptions.appearance ? defaultOptions.appearance : 'standard'; this.hideSingleSelectionIndicator = defaultOptions?.hideSingleSelectionIndicator ?? false; this.hideMultipleSelectionIndicator = defaultOptions?.hideMultipleSelectionIndicator ?? false; } ngOnInit() { this._selectionModel = new SelectionModel(this.multiple, undefined, false); } ngAfterContentInit() { this._selectionModel.select(...this._buttonToggles.filter(toggle => toggle.checked)); if (!this.multiple) { this._initializeTabIndex(); } } /** * Sets the model value. Implemented as part of ControlValueAccessor. * @param value Value to be set to the model. */ writeValue(value: any) { this.value = value; this._changeDetector.markForCheck(); } // Implemented as part of ControlValueAccessor. registerOnChange(fn: (value: any) => void) { this._controlValueAccessorChangeFn = fn; } // Implemented as part of ControlValueAccessor. registerOnTouched(fn: any) { this._onTouched = fn; } // Implemented as part of ControlValueAccessor. setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } /** Handle keydown event calling to single-select button toggle. */ protected _keydown(event: KeyboardEvent) { if (this.multiple || this.disabled) { return; } const target = event.target as HTMLButtonElement; const buttonId = target.id; const index = this._buttonToggles.toArray().findIndex(toggle => { return toggle.buttonId === buttonId; }); let nextButton: MatButtonToggle | null = null; switch (event.keyCode) { case SPACE: case ENTER: nextButton = this._buttonToggles.get(index) || null; break; case UP_ARROW: nextButton = this._getNextButton(index, -1); break; case LEFT_ARROW: nextButton = this._getNextButton(index, this.dir === 'ltr' ? -1 : 1); break; case DOWN_ARROW: nextButton = this._getNextButton(index, 1); break; case RIGHT_ARROW: nextButton = this._getNextButton(index, this.dir === 'ltr' ? 1 : -1); break; default: return; } if (nextButton) { event.preventDefault(); nextButton._onButtonClick(); nextButton.focus(); } } /** Dispatch change event with current selection and group value. */ _emitChangeEvent(toggle: MatButtonToggle): void { const event = new MatButtonToggleChange(toggle, this.value); this._rawValue = event.value; this._controlValueAccessorChangeFn(event.value); this.change.emit(event); } /** * Syncs a button toggle's selected state with the model value. * @param toggle Toggle to be synced. * @param select Whether the toggle should be selected. * @param isUserInput Whether the change was a result of a user interaction. * @param deferEvents Whether to defer emitting the change events. */ _syncButtonToggle( toggle: MatButtonToggle, select: boolean, isUserInput = false, deferEvents = false, ) { // Deselect the currently-selected toggle, if we're in single-selection // mode and the button being toggled isn't selected at the moment. if (!this.multiple && this.selected && !toggle.checked) { (this.selected as MatButtonToggle).checked = false; } if (this._selectionModel) { if (select) { this._selectionModel.select(toggle); } else { this._selectionModel.deselect(toggle); } } else { deferEvents = true; } // We need to defer in some cases in order to avoid "changed after checked errors", however // the side-effect is that we may end up updating the model value out of sequence in others // The `deferEvents` flag allows us to decide whether to do it on a case-by-case basis. if (deferEvents) { Promise.resolve().then(() => this._updateModelValue(toggle, isUserInput)); } else { this._updateModelValue(toggle, isUserInput); } } /** Checks whether a button toggle is selected. */ _isSelected(toggle: MatButtonToggle) { return this._selectionModel && this._selectionModel.isSelected(toggle); } /** Determines whether a button toggle should be checked on init. */ _isPrechecked(toggle: MatButtonToggle) { if (typeof this._rawValue === 'undefined') { return false; } if (this.multiple && Array.isArray(this._rawValue)) { return this._rawValue.some(value => toggle.value != null && value === toggle.value); } return toggle.value === this._rawValue; } /** Initializes the tabindex attribute using the radio pattern. */ private _initializeTabIndex() { this._buttonToggles.forEach(toggle => { toggle.tabIndex = -1; }); if (this.selected) { (this.selected as MatButtonToggle).tabIndex = 0; } else { for (let i = 0; i < this._buttonToggles.length; i++) { const toggle = this._buttonToggles.get(i)!; if (!toggle.disabled) { toggle.tabIndex = 0; break; } } } this._markButtonsForCheck(); } /** Obtain the subsequent toggle to which the focus shifts. */ private _getNextButton(startIndex: number, offset: number): MatButtonToggle | null { const items = this._buttonToggles; for (let i = 1; i <= items.length; i++) { const index = (startIndex + offset * i + items.length) % items.length; const item = items.get(index); if (item && !item.disabled) { return item; } } return null; } /** Updates the selection state of the toggles in the group based on a value. */ private _setSelectionByValue(value: any | any[]) { this._rawValue = value; if (!this._buttonToggles) { return; } const toggles = this._buttonToggles.toArray(); if (this.multiple && value) { if (!Array.isArray(value) && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw Error('Value must be an array in multiple-selection mode.'); } this._clearSelection(); value.forEach((currentValue: any) => this._selectValue(currentValue, toggles)); } else { this._clearSelection(); this._selectValue(value, toggles); } // In single selection mode we need at least one enabled toggle to always be focusable. if (!this.multiple && toggles.every(toggle => toggle.tabIndex === -1)) { for (const toggle of toggles) { if (!toggle.disabled) { toggle.tabIndex = 0; break; } } } } /** Clears the selected toggles. */ private _clearSelection() { this._selectionModel.clear(); this._buttonToggles.forEach(toggle => { toggle.checked = false; // If the button toggle is in single select mode, initialize the tabIndex. if (!this.multiple) { toggle.tabIndex = -1; } }); } /** Selects a value if there's a toggle that corresponds to it. */ private _selectValue(value: any, toggles: MatButtonToggle[]) { for (const toggle of toggles) { if (toggle.value != null && toggle.value === value) { toggle.checked = true; this._selectionModel.select(toggle); if (!this.multiple) { // If the button toggle is in single select mode, reset the tabIndex. toggle.tabIndex = 0; } break; } } } /** Syncs up the group's value with the model and emits the change event. */ private _updateModelValue(toggle: MatButtonToggle, isUserInput: boolean) { // Only emit the change event for user input. if (isUserInput) { this._emitChangeEvent(toggle); } // Note: we emit this one no matter whether it was a user interaction, because // it is used by Angular to sync up the two-way data binding. this.valueChange.emit(this.value); } /** Marks all of the child button toggles to be checked. */ private _markButtonsForCheck() { this._buttonToggles?.forEach(toggle => toggle._markForCheck()); } } /** Single button inside of a toggle group. */ @Component({ selector: 'mat-button-toggle', templateUrl: 'button-toggle.html', styleUrl: 'button-toggle.css', encapsulation: ViewEncapsulation.None, exportAs: 'matButtonToggle', changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class.mat-button-toggle-standalone]': '!buttonToggleGroup', '[class.mat-button-toggle-checked]': 'checked', '[class.mat-button-toggle-disabled]': 'disabled', '[class.mat-button-toggle-disabled-interactive]': 'disabledInteractive', '[class.mat-button-toggle-appearance-standard]': 'appearance === "standard"', 'class': 'mat-button-toggle', '[attr.aria-label]': 'null', '[attr.aria-labelledby]': 'null', '[attr.id]': 'id', '[attr.name]': 'null', '(focus)': 'focus()', 'role': 'presentation', }, imports: [MatRipple, MatPseudoCheckbox], }) export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy { private _changeDetectorRef = inject(ChangeDetectorRef); private _elementRef = inject>(ElementRef); private _focusMonitor = inject(FocusMonitor); private _idGenerator = inject(_IdGenerator); private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true}); private _checked = false; /** * Attached to the aria-label attribute of the host element. In most cases, aria-labelledby will * take precedence so this may be omitted. */ @Input('aria-label') ariaLabel: string; /** * Users can specify the `aria-labelledby` attribute which will be forwarded to the input element */ @Input('aria-labelledby') ariaLabelledby: string | null = null; /** Underlying native `button` element. */ @ViewChild('button') _buttonElement: ElementRef; /** The parent button toggle group (exclusive selection). Optional. */ buttonToggleGroup: MatButtonToggleGroup; /** Unique ID for the underlying `button` element. */ get buttonId(): string { return `${this.id}-button`; } /** The unique ID for this button toggle. */ @Input() id: string; /** HTML's 'name' attribute used to group radios for unique selection. */ @Input() name: string; /** MatButtonToggleGroup reads this to assign its own value. */ @Input() value: any; /** Tabindex of the toggle. */ @Input() get tabIndex(): number | null { return this._tabIndex; } set tabIndex(value: number | null) { if (value !== this._tabIndex) { this._tabIndex = value; this._markForCheck(); } } private _tabIndex: number | null; /** Whether ripples are disabled on the button toggle. */ @Input({transform: booleanAttribute}) disableRipple: boolean; /** The appearance style of the button. */ @Input() get appearance(): MatButtonToggleAppearance { return this.buttonToggleGroup ? this.buttonToggleGroup.appearance : this._appearance; } set appearance(value: MatButtonToggleAppearance) { this._appearance = value; } private _appearance: MatButtonToggleAppearance; /** Whether the button is checked. */ @Input({transform: booleanAttribute}) get checked(): boolean { return this.buttonToggleGroup ? this.buttonToggleGroup._isSelected(this) : this._checked; } set checked(value: boolean) { if (value !== this._checked) { this._checked = value; if (this.buttonToggleGroup) { this.buttonToggleGroup._syncButtonToggle(this, this._checked); } this._changeDetectorRef.markForCheck(); } } /** Whether the button is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled || (this.buttonToggleGroup && this.buttonToggleGroup.disabled); } set disabled(value: boolean) { this._disabled = value; } private _disabled: boolean = false; /** Whether the button should remain interactive when it is disabled. */ @Input({transform: booleanAttribute}) get disabledInteractive(): boolean { return ( this._disabledInteractive || (this.buttonToggleGroup !== null && this.buttonToggleGroup.disabledInteractive) ); } set disabledInteractive(value: boolean) { this._disabledInteractive = value; } private _disabledInteractive: boolean; /** Event emitted when the group value changes. */ @Output() readonly change: EventEmitter = new EventEmitter(); constructor(...args: unknown[]); constructor() { inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader); const toggleGroup = inject(MAT_BUTTON_TOGGLE_GROUP, {optional: true})!; const defaultTabIndex = inject(new HostAttributeToken('tabindex'), {optional: true}) || ''; const defaultOptions = inject( MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS, {optional: true}, ); this._tabIndex = parseInt(defaultTabIndex) || 0; this.buttonToggleGroup = toggleGroup; this.appearance = defaultOptions && defaultOptions.appearance ? defaultOptions.appearance : 'standard'; this.disabledInteractive = defaultOptions?.disabledInteractive ?? false; } ngOnInit() { const group = this.buttonToggleGroup; this.id = this.id || this._idGenerator.getId('mat-button-toggle-'); if (group) { if (group._isPrechecked(this)) { this.checked = true; } else if (group._isSelected(this) !== this._checked) { // As side effect of the circular dependency between the toggle group and the button, // we may end up in a state where the button is supposed to be checked on init, but it // isn't, because the checked value was assigned too early. This can happen when Ivy // assigns the static input value before the `ngOnInit` has run. group._syncButtonToggle(this, this._checked); } } } ngAfterViewInit() { // This serves two purposes: // 1. We don't want the animation to fire on the first render for pre-checked toggles so we // delay adding the class until the view is rendered. // 2. We don't want animation if the `NoopAnimationsModule` is provided. if (this._animationMode !== 'NoopAnimations') { this._elementRef.nativeElement.classList.add('mat-button-toggle-animations-enabled'); } this._focusMonitor.monitor(this._elementRef, true); } ngOnDestroy() { const group = this.buttonToggleGroup; this._focusMonitor.stopMonitoring(this._elementRef); // Remove the toggle from the selection once it's destroyed. Needs to happen // on the next tick in order to avoid "changed after checked" errors. if (group && group._isSelected(this)) { group._syncButtonToggle(this, false, false, true); } } /** Focuses the button. */ focus(options?: FocusOptions): void { this._buttonElement.nativeElement.focus(options); } /** Checks the button toggle due to an interaction with the underlying native button. */ _onButtonClick() { if (this.disabled) { return; } const newChecked = this.isSingleSelector() ? true : !this._checked; if (newChecked !== this._checked) { this._checked = newChecked; if (this.buttonToggleGroup) { this.buttonToggleGroup._syncButtonToggle(this, this._checked, true); this.buttonToggleGroup._onTouched(); } } if (this.isSingleSelector()) { const focusable = this.buttonToggleGroup._buttonToggles.find(toggle => { return toggle.tabIndex === 0; }); // Modify the tabindex attribute of the last focusable button toggle to -1. if (focusable) { focusable.tabIndex = -1; } // Modify the tabindex attribute of the presently selected button toggle to 0. this.tabIndex = 0; } // Emit a change event when it's the single selector this.change.emit(new MatButtonToggleChange(this, this.value)); } /** * Marks the button toggle as needing checking for change detection. * This method is exposed because the parent button toggle group will directly * update bound properties of the radio button. */ _markForCheck() { // When the group value changes, the button will not be notified. // Use `markForCheck` to explicit update button toggle's status. this._changeDetectorRef.markForCheck(); } /** Gets the name that should be assigned to the inner DOM node. */ _getButtonName(): string | null { if (this.isSingleSelector()) { return this.buttonToggleGroup.name; } return this.name || null; } /** Whether the toggle is in single selection mode. */ isSingleSelector(): boolean { return this.buttonToggleGroup && !this.buttonToggleGroup.multiple; } }