sass-references/angular-material/material/checkbox/checkbox.ts

549 lines
19 KiB
TypeScript
Raw Normal View History

2024-12-06 10:42:08 +08:00
/**
* @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 {
ANIMATION_MODULE_TYPE,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
NgZone,
OnChanges,
Output,
SimpleChanges,
ViewChild,
ViewEncapsulation,
booleanAttribute,
forwardRef,
numberAttribute,
inject,
HostAttributeToken,
} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
} from '@angular/forms';
import {MatRipple, _MatInternalFormField, _StructuralStylesLoader} from '@angular/material/core';
import {
MAT_CHECKBOX_DEFAULT_OPTIONS,
MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY,
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,
}
/**
* @deprecated Will stop being exported.
* @breaking-change 19.0.0
*/
export const MAT_CHECKBOX_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MatCheckbox),
multi: true,
};
/** 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;
}
// Default checkbox configuration.
const defaults = MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY();
@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]': `_animationMode === 'NoopAnimations'`,
'[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: [
MAT_CHECKBOX_CONTROL_VALUE_ACCESSOR,
{
provide: NG_VALIDATORS,
useExisting: MatCheckbox,
multi: true,
},
],
exportAs: 'matCheckbox',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
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);
_animationMode? = inject(ANIMATION_MODULE_TYPE, {optional: true});
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;
/** 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;
/** The native `<input type="checkbox">` element */
@ViewChild('input') _inputElement: ElementRef<HTMLInputElement>;
/** The native `<label>` element */
@ViewChild('label') _labelElement: 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 information on applying color variants in M3, see
* https://material.angular.io/guide/theming#using-component-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(...args: unknown[]);
constructor() {
inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader);
const tabIndex = inject(new HostAttributeToken('tabindex'), {optional: true});
this._options = this._options || defaults;
this.color = this._options.color || defaults.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) {
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 = value;
if (changed) {
if (this._indeterminate) {
this._transitionCheckState(TransitionCheckState.Indeterminate);
} else {
this._transitionCheckState(
this.checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked,
);
}
this.indeterminateChange.emit(this._indeterminate);
}
this._syncIndeterminate(this._indeterminate);
}
private _indeterminate: boolean = 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 = false;
this.indeterminateChange.emit(this._indeterminate);
});
}
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._animationMode === 'NoopAnimations') {
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();
}
_onTouchTargetClick() {
this._handleInputClick();
if (!this.disabled) {
// 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();
}
}
/**
* 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._labelElement.nativeElement.contains(event.target as HTMLElement)) {
event.stopPropagation();
}
}
}