/
githubmirror
/
components
Обзор
Документация
Войти
/
githubmirror
/
components
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/material/checkbox/checkbox.ts
527 строк
18 KB
Kristiyan Kostadinov
fix(material/checkbox): avoid making the touch target a focus stop (#33584)
30 июл 2026, 18:49
Не верифицирован
30 июл 2026, 18:49
3fb737c
Код
Авторство
О чём код?
/** * @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, FocusableOption} from '@angular/cdk/a11y'; import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, NgZone, OnChanges, Output, SimpleChanges, ViewChild, ViewEncapsulation, booleanAttribute, forwardRef, numberAttribute, inject, HostAttributeToken, signal, } from '@angular/core'; import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, } from '@angular/forms'; import { MatRipple, _MatInternalFormField, _StructuralStylesLoader, _animationsDisabled, } from '../core'; import { checkboxDefaults, MAT_CHECKBOX_DEFAULT_OPTIONS, MatCheckboxDefaultOptions, } from './checkbox-config'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; /** * Represents the different states that require custom transitions between them. * @docs-private */ export enum TransitionCheckState { /** The initial state of the component before any user interaction. */ Init, /** The state representing the component when it's becoming checked. */ Checked, /** The state representing the component when it's becoming unchecked. */ Unchecked, /** The state representing the component when it's becoming indeterminate. */ Indeterminate, } /** Change event object emitted by checkbox. */ export class MatCheckboxChange { /** The source checkbox of the event. */ source!: MatCheckbox; /** The new `checked` value of the checkbox. */ checked!: boolean; } @Component({ selector: 'mat-checkbox', templateUrl: 'checkbox.html', styleUrl: 'checkbox.css', host: { 'class': 'mat-mdc-checkbox', '[attr.tabindex]': 'null', '[attr.aria-label]': 'null', '[attr.aria-labelledby]': 'null', '[class._mat-animation-noopable]': '_animationsDisabled', '[class.mdc-checkbox--disabled]': 'disabled', '[id]': 'id', // Add classes that users can use to more easily target disabled or checked checkboxes. '[class.mat-mdc-checkbox-disabled]': 'disabled', '[class.mat-mdc-checkbox-checked]': 'checked', '[class.mat-mdc-checkbox-disabled-interactive]': 'disabledInteractive', '[class]': 'color ? "mat-" + color : "mat-accent"', }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MatCheckbox), multi: true, }, { provide: NG_VALIDATORS, useExisting: MatCheckbox, multi: true, }, ], exportAs: 'matCheckbox', encapsulation: ViewEncapsulation.None, imports: [MatRipple, _MatInternalFormField], }) export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccessor, Validator, FocusableOption { _elementRef = inject<ElementRef<HTMLElement>>(ElementRef); private _changeDetectorRef = inject(ChangeDetectorRef); private _ngZone = inject(NgZone); protected _animationsDisabled = _animationsDisabled(); private _options = inject<MatCheckboxDefaultOptions>(MAT_CHECKBOX_DEFAULT_OPTIONS, { optional: true, }); /** Focuses the checkbox. */ focus() { this._inputElement.nativeElement.focus(); } /** Creates the change event that will be emitted by the checkbox. */ protected _createChangeEvent(isChecked: boolean) { const event = new MatCheckboxChange(); event.source = this; event.checked = isChecked; return event; } /** Gets the element on which to add the animation CSS classes. */ protected _getAnimationTargetElement() { return this._inputElement?.nativeElement; } /** CSS classes to add when transitioning between the different checkbox states. */ protected _animationClasses = { uncheckedToChecked: 'mdc-checkbox--anim-unchecked-checked', uncheckedToIndeterminate: 'mdc-checkbox--anim-unchecked-indeterminate', checkedToUnchecked: 'mdc-checkbox--anim-checked-unchecked', checkedToIndeterminate: 'mdc-checkbox--anim-checked-indeterminate', indeterminateToChecked: 'mdc-checkbox--anim-indeterminate-checked', indeterminateToUnchecked: 'mdc-checkbox--anim-indeterminate-unchecked', }; /** * 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; /** The 'aria-describedby' attribute is read after the element's label and field type. */ @Input('aria-describedby') ariaDescribedby!: string; /** * Users can specify the `aria-expanded` attribute which will be forwarded to the input element */ @Input({alias: 'aria-expanded', transform: booleanAttribute}) ariaExpanded!: boolean; /** * Users can specify the `aria-controls` attribute which will be forwarded to the input element */ @Input('aria-controls') ariaControls!: string; /** Users can specify the `aria-owns` attribute which will be forwarded to the input element */ @Input('aria-owns') ariaOwns!: string; private _uniqueId: string; /** A unique id for the checkbox input. If none is supplied, it will be auto-generated. */ @Input() id: string; /** Returns the unique id for the visual hidden input. */ get inputId(): string { return `${this.id || this._uniqueId}-input`; } /** Whether the checkbox is required. */ @Input({transform: booleanAttribute}) required: boolean = false; /** Whether the label should appear after or before the checkbox. Defaults to 'after' */ @Input() labelPosition: 'before' | 'after' = 'after'; /** Name value will be applied to the input element if present */ @Input() name: string | null = null; /** Event emitted when the checkbox's `checked` value changes. */ @Output() readonly change = new EventEmitter<MatCheckboxChange>(); /** Event emitted when the checkbox's `indeterminate` value changes. */ @Output() readonly indeterminateChange: EventEmitter<boolean> = new EventEmitter<boolean>(); /** The value attribute of the native input element */ @Input() value!: string; /** Whether the checkbox has a ripple. */ @Input({transform: booleanAttribute}) disableRipple: boolean = false; /** The native `<input type="checkbox">` element */ @ViewChild('input') _inputElement!: ElementRef<HTMLInputElement>; /** Tabindex for the checkbox. */ @Input({transform: (value: unknown) => (value == null ? undefined : numberAttribute(value))}) tabIndex: number; // TODO(crisbeto): this should be a ThemePalette, but some internal apps were abusing // the lack of type checking previously and assigning random strings. /** * Theme color of the checkbox. This API is supported in M2 themes only, it * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/checkbox/styling. * * For information on applying color variants in M3, see * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: string | undefined; /** Whether the checkbox should remain interactive when it is disabled. */ @Input({transform: booleanAttribute}) disabledInteractive: boolean; /** * Called when the checkbox is blurred. Needed to properly implement ControlValueAccessor. * @docs-private */ _onTouched: () => any = () => {}; private _currentAnimationClass: string = ''; private _currentCheckState: TransitionCheckState = TransitionCheckState.Init; private _controlValueAccessorChangeFn: (value: any) => void = () => {}; private _validatorChangeFn = () => {}; constructor() { inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader); const tabIndex = inject(new HostAttributeToken('tabindex'), {optional: true}); this._options = this._options || checkboxDefaults; this.color = this._options.color || checkboxDefaults.color; this.tabIndex = tabIndex == null ? 0 : parseInt(tabIndex) || 0; this.id = this._uniqueId = inject(_IdGenerator).getId('mat-mdc-checkbox-'); this.disabledInteractive = this._options?.disabledInteractive ?? false; } ngOnChanges(changes: SimpleChanges<this>) { if (changes['required']) { this._validatorChangeFn(); } } ngAfterViewInit() { this._syncIndeterminate(this.indeterminate); } /** Whether the checkbox is checked. */ @Input({transform: booleanAttribute}) get checked(): boolean { return this._checked; } set checked(value: boolean) { if (value != this.checked) { this._checked = value; this._changeDetectorRef.markForCheck(); } } private _checked: boolean = false; /** Whether the checkbox is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled; } set disabled(value: boolean) { if (value !== this.disabled) { this._disabled = value; this._changeDetectorRef.markForCheck(); } } private _disabled: boolean = false; /** * Whether the checkbox is indeterminate. This is also known as "mixed" mode and can be used to * represent a checkbox with three states, e.g. a checkbox that represents a nested list of * checkable items. Note that whenever checkbox is manually clicked, indeterminate is immediately * set to false. */ @Input({transform: booleanAttribute}) get indeterminate(): boolean { return this._indeterminate(); } set indeterminate(value: boolean) { const changed = value != this._indeterminate(); this._indeterminate.set(value); if (changed) { if (value) { this._transitionCheckState(TransitionCheckState.Indeterminate); } else { this._transitionCheckState( this.checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked, ); } this.indeterminateChange.emit(value); } this._syncIndeterminate(value); } private _indeterminate = signal(false); _isRippleDisabled() { return this.disableRipple || this.disabled; } /** Method being called whenever the label text changes. */ _onLabelTextChange() { // Since the event of the `cdkObserveContent` directive runs outside of the zone, the checkbox // component will be only marked for check, but no actual change detection runs automatically. // Instead of going back into the zone in order to trigger a change detection which causes // *all* components to be checked (if explicitly marked or not using OnPush), we only trigger // an explicit change detection for the checkbox view and its children. this._changeDetectorRef.detectChanges(); } // Implemented as part of ControlValueAccessor. writeValue(value: any) { this.checked = !!value; } // 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) { this.disabled = isDisabled; } // Implemented as a part of Validator. validate(control: AbstractControl<boolean>): ValidationErrors | null { return this.required && control.value !== true ? {'required': true} : null; } // Implemented as a part of Validator. registerOnValidatorChange(fn: () => void): void { this._validatorChangeFn = fn; } private _transitionCheckState(newState: TransitionCheckState) { let oldState = this._currentCheckState; let element = this._getAnimationTargetElement(); if (oldState === newState || !element) { return; } if (this._currentAnimationClass) { element.classList.remove(this._currentAnimationClass); } this._currentAnimationClass = this._getAnimationClassForCheckStateTransition( oldState, newState, ); this._currentCheckState = newState; if (this._currentAnimationClass.length > 0) { element.classList.add(this._currentAnimationClass); // Remove the animation class to avoid animation when the checkbox is moved between containers const animationClass = this._currentAnimationClass; this._ngZone.runOutsideAngular(() => { setTimeout(() => { element!.classList.remove(animationClass); }, 1000); }); } } private _emitChangeEvent() { this._controlValueAccessorChangeFn(this.checked); this.change.emit(this._createChangeEvent(this.checked)); // Assigning the value again here is redundant, but we have to do it in case it was // changed inside the `change` listener which will cause the input to be out of sync. if (this._inputElement) { this._inputElement.nativeElement.checked = this.checked; } } /** Toggles the `checked` state of the checkbox. */ toggle(): void { this.checked = !this.checked; this._controlValueAccessorChangeFn(this.checked); } protected _handleInputClick() { const clickAction = this._options?.clickAction; // If resetIndeterminate is false, and the current state is indeterminate, do nothing on click if (!this.disabled && clickAction !== 'noop') { // When user manually click on the checkbox, `indeterminate` is set to false. if (this.indeterminate && clickAction !== 'check') { Promise.resolve().then(() => { this._indeterminate.set(false); this.indeterminateChange.emit(false); }); } this._checked = !this._checked; this._transitionCheckState( this._checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked, ); // Emit our custom change event if the native input emitted one. // It is important to only emit it, if the native input triggered one, because // we don't want to trigger a change event, when the `checked` variable changes for example. this._emitChangeEvent(); } else if ( (this.disabled && this.disabledInteractive) || (!this.disabled && clickAction === 'noop') ) { // Reset native input when clicked with noop. The native checkbox becomes checked after // click, reset it to be align with `checked` value of `mat-checkbox`. this._inputElement.nativeElement.checked = this.checked; this._inputElement.nativeElement.indeterminate = this.indeterminate; } } _onInteractionEvent(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(); } _onBlur() { // When a focused element becomes disabled, the browser *immediately* fires a blur event. // Angular does not expect events to be raised during change detection, so any state change // (such as a form control's 'ng-touched') will cause a changed-after-checked error. // See https://github.com/angular/angular/issues/17793. To work around this, we defer // telling the form control it has been touched until the next tick. Promise.resolve().then(() => { this._onTouched(); this._changeDetectorRef.markForCheck(); }); } private _getAnimationClassForCheckStateTransition( oldState: TransitionCheckState, newState: TransitionCheckState, ): string { // Don't transition if animations are disabled. if (this._animationsDisabled) { return ''; } switch (oldState) { case TransitionCheckState.Init: // Handle edge case where user interacts with checkbox that does not have [(ngModel)] or // [checked] bound to it. if (newState === TransitionCheckState.Checked) { return this._animationClasses.uncheckedToChecked; } else if (newState == TransitionCheckState.Indeterminate) { return this._checked ? this._animationClasses.checkedToIndeterminate : this._animationClasses.uncheckedToIndeterminate; } break; case TransitionCheckState.Unchecked: return newState === TransitionCheckState.Checked ? this._animationClasses.uncheckedToChecked : this._animationClasses.uncheckedToIndeterminate; case TransitionCheckState.Checked: return newState === TransitionCheckState.Unchecked ? this._animationClasses.checkedToUnchecked : this._animationClasses.checkedToIndeterminate; case TransitionCheckState.Indeterminate: return newState === TransitionCheckState.Checked ? this._animationClasses.indeterminateToChecked : this._animationClasses.indeterminateToUnchecked; } return ''; } /** * Syncs the indeterminate value with the checkbox DOM node. * * We sync `indeterminate` directly on the DOM node, because in Ivy the check for whether a * property is supported on an element boils down to `if (propName in element)`. Domino's * HTMLInputElement doesn't have an `indeterminate` property so Ivy will warn during * server-side rendering. */ private _syncIndeterminate(value: boolean) { const nativeCheckbox = this._inputElement; if (nativeCheckbox) { nativeCheckbox.nativeElement.indeterminate = value; } } _onInputClick() { this._handleInputClick(); } /** * Prevent click events that come from the `<label/>` element from bubbling. This prevents the * click handler on the host from triggering twice when clicking on the `<label/>` element. After * the click event on the `<label/>` propagates, the browsers dispatches click on the associated * `<input/>`. By preventing clicks on the label by bubbling, we ensure only one click event * bubbles when the label is clicked. */ _preventBubblingFromLabel(event: MouseEvent) { if (event.target && this._inputElement && event.target !== this._inputElement.nativeElement) { event.stopPropagation(); } } }