From 7ffd195f572f93b7538012688036112988b9b02b Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 16 May 2022 19:46:26 +0200 Subject: [PATCH] refactor(material-experimental/mdc-checkbox): remove usage of MDC adapter Moves all of the checkbox logic into a base class which is used to replace the MDC checkbox adapter. --- .../mdc-checkbox/checkbox.html | 9 +- .../mdc-checkbox/checkbox.spec.ts | 3 + .../mdc-checkbox/checkbox.ts | 344 ++---------------- src/material/checkbox/checkbox.ts | 305 +++++++++------- tools/public_api_guard/material/checkbox.md | 64 +++- 5 files changed, 273 insertions(+), 452 deletions(-) diff --git a/src/material-experimental/mdc-checkbox/checkbox.html b/src/material-experimental/mdc-checkbox/checkbox.html index 7e28c481479f..1f8fb9d66f40 100644 --- a/src/material-experimental/mdc-checkbox/checkbox.html +++ b/src/material-experimental/mdc-checkbox/checkbox.html @@ -2,10 +2,11 @@ [class.mdc-form-field--align-end]="labelPosition == 'before'">
-
-
+ + (click)="_onInputClick()" + (change)="_onInteractionEvent($event)"/>
{ testComponent.isIndeterminate = true; fixture.detectChanges(); + flush(); expect(inputElement.checked).toBe(false); expect(inputElement.indeterminate).toBe(true); @@ -106,6 +107,7 @@ describe('MDC-based MatCheckbox', () => { testComponent.isIndeterminate = false; fixture.detectChanges(); + flush(); expect(inputElement.checked).toBe(false); expect(inputElement.indeterminate).toBe(false); @@ -164,6 +166,7 @@ describe('MDC-based MatCheckbox', () => { it('should not set indeterminate to false when checked is set programmatically', fakeAsync(() => { testComponent.isIndeterminate = true; fixture.detectChanges(); + flush(); expect(checkboxInstance.indeterminate).toBe(true); expect(inputElement.indeterminate).toBe(true); diff --git a/src/material-experimental/mdc-checkbox/checkbox.ts b/src/material-experimental/mdc-checkbox/checkbox.ts index c7ff04ad77c7..7005098f9528 100644 --- a/src/material-experimental/mdc-checkbox/checkbox.ts +++ b/src/material-experimental/mdc-checkbox/checkbox.ts @@ -6,44 +6,26 @@ * found in the LICENSE file at https://angular.io/license */ -import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import { - AfterViewInit, Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, - EventEmitter, forwardRef, Inject, - Input, - OnDestroy, + NgZone, Optional, - Output, - ViewChild, ViewEncapsulation, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import { MAT_CHECKBOX_DEFAULT_OPTIONS, MatCheckboxDefaultOptions, - MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY, + _MatCheckboxBase, } from '@angular/material/checkbox'; -import { - mixinColor, - mixinDisabled, - CanColor, - CanDisable, - MatRipple, -} from '@angular/material-experimental/mdc-core'; +import {CanColor, CanDisable} from '@angular/material-experimental/mdc-core'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; -import {MDCCheckboxAdapter, MDCCheckboxFoundation} from '@material/checkbox'; - -let nextUniqueId = 0; - -// Default checkbox configuration. -const defaults = MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY(); export const MAT_CHECKBOX_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, @@ -59,21 +41,10 @@ export class MatCheckboxChange { checked: boolean; } -// Boilerplate for applying mixins to MatCheckbox. -/** @docs-private */ -const _MatCheckboxBase = mixinColor( - mixinDisabled( - class { - constructor(public _elementRef: ElementRef) {} - }, - ), -); - @Component({ selector: 'mat-checkbox', templateUrl: 'checkbox.html', styleUrls: ['checkbox.css'], - inputs: ['color', 'disabled'], host: { 'class': 'mat-mdc-checkbox', '[attr.tabindex]': 'null', @@ -87,305 +58,62 @@ const _MatCheckboxBase = mixinColor( '[class.mat-mdc-checkbox-checked]': 'checked', }, providers: [MAT_CHECKBOX_CONTROL_VALUE_ACCESSOR], + inputs: ['disableRipple', 'color', 'tabIndex'], exportAs: 'matCheckbox', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatCheckbox - extends _MatCheckboxBase - implements AfterViewInit, OnDestroy, ControlValueAccessor, CanColor, CanDisable + extends _MatCheckboxBase + implements ControlValueAccessor, CanColor, CanDisable { - /** - * The `aria-label` attribute to use for the input element. In most cases, `aria-labelledby` will - * take precedence so this may be omitted. - */ - @Input('aria-label') ariaLabel: string = ''; - - /** The `aria-labelledby` attribute to use for 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; - - /** Whether the label should appear after or before the checkbox. Defaults to 'after'. */ - @Input() labelPosition: 'before' | 'after' = 'after'; - - /** The `name` attribute to use for the input element. */ - @Input() name: string | null = null; - - /** The `tabindex` attribute to use for the input element. */ - @Input() tabIndex: number; - - /** The `value` attribute to use for the input element */ - @Input() value: string; - - private _uniqueId = `mat-mdc-checkbox-${++nextUniqueId}`; - - /** A unique id for the checkbox. If none is supplied, it will be auto-generated. */ - @Input() id: string = this._uniqueId; - - /** Whether the checkbox is checked. */ - @Input() - get checked(): boolean { - return this._checked; - } - set checked(checked: BooleanInput) { - this._checked = coerceBooleanProperty(checked); - this._changeDetectorRef.markForCheck(); - } - private _checked = 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() - get indeterminate(): boolean { - return this._indeterminate; - } - set indeterminate(indeterminate: BooleanInput) { - this._indeterminate = coerceBooleanProperty(indeterminate); - this._syncIndeterminate(this._indeterminate); - } - private _indeterminate = false; - - /** Whether the checkbox is required. */ - @Input() - get required(): boolean { - return this._required; - } - set required(required: BooleanInput) { - this._required = coerceBooleanProperty(required); - } - private _required = false; - - /** Whether to disable the ripple on this checkbox. */ - @Input() - get disableRipple(): boolean { - return this._disableRipple; - } - set disableRipple(disableRipple: BooleanInput) { - this._disableRipple = coerceBooleanProperty(disableRipple); - } - private _disableRipple = false; - - /** Event emitted when the checkbox's `checked` value changes. */ - @Output() - readonly change: EventEmitter = new EventEmitter(); - - /** Event emitted when the checkbox's `indeterminate` value changes. */ - @Output() readonly indeterminateChange: EventEmitter = new EventEmitter(); - - /** The root element for the `MDCCheckbox`. */ - @ViewChild('checkbox') _checkbox: ElementRef; - - /** The native input element. */ - @ViewChild('nativeCheckbox') _nativeCheckbox: ElementRef; - - /** The native label element. */ - @ViewChild('label') _label: ElementRef; - - /** Reference to the ripple instance of the checkbox. */ - @ViewChild(MatRipple) ripple: MatRipple; - - /** Returns the unique id for the visual hidden input. */ - get inputId(): string { - return `${this.id || this._uniqueId}-input`; - } - - /** The `MDCCheckboxFoundation` instance for this checkbox. */ - _checkboxFoundation: MDCCheckboxFoundation; - - /** ControlValueAccessor onChange */ - private _cvaOnChange = (_: boolean) => {}; - - /** ControlValueAccessor onTouch */ - private _cvaOnTouch = () => {}; - - /** - * A list of attributes that should not be modified by `MDCFoundation` classes. - * - * MDC uses animation events to determine when to update `aria-checked` which is unreliable. - * Therefore we disable it and handle it ourselves. - */ - private _mdcFoundationIgnoredAttrs = new Set(['aria-checked']); - - /** The `MDCCheckboxAdapter` instance for this checkbox. */ - private _checkboxAdapter: MDCCheckboxAdapter = { - addClass: className => this._nativeCheckbox.nativeElement.classList.add(className), - removeClass: className => this._nativeCheckbox.nativeElement.classList.remove(className), - forceLayout: () => this._checkbox.nativeElement.offsetWidth, - hasNativeControl: () => !!this._nativeCheckbox, - isAttachedToDOM: () => !!this._checkbox.nativeElement.parentNode, - isChecked: () => this.checked, - isIndeterminate: () => this.indeterminate, - removeNativeControlAttr: attr => { - if (!this._mdcFoundationIgnoredAttrs.has(attr)) { - this._nativeCheckbox.nativeElement.removeAttribute(attr); - } - }, - setNativeControlAttr: (attr, value) => { - if (!this._mdcFoundationIgnoredAttrs.has(attr)) { - this._nativeCheckbox.nativeElement.setAttribute(attr, value); - } - }, - setNativeControlDisabled: disabled => (this.disabled = disabled), + 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', }; constructor( - private _changeDetectorRef: ChangeDetectorRef, elementRef: ElementRef, + changeDetectorRef: ChangeDetectorRef, + ngZone: NgZone, @Attribute('tabindex') tabIndex: string, - @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, @Optional() @Inject(MAT_CHECKBOX_DEFAULT_OPTIONS) - private _options?: MatCheckboxDefaultOptions, + options?: MatCheckboxDefaultOptions, ) { - super(elementRef); - // Note: We don't need to set up the MDCFormFieldFoundation. Its only purpose is to manage the - // ripple, which we do ourselves instead. - this.tabIndex = parseInt(tabIndex) || 0; - this._checkboxFoundation = new MDCCheckboxFoundation(this._checkboxAdapter); - this._options = this._options || defaults; - this.color = this.defaultColor = this._options!.color || defaults.color; - } - - ngAfterViewInit() { - this._syncIndeterminate(this._indeterminate); - this._checkboxFoundation.init(); - } - - ngOnDestroy() { - this._checkboxFoundation.destroy(); - } - - /** - * Implemented as part of `ControlValueAccessor` - * @docs-private - */ - registerOnChange(fn: (checked: boolean) => void) { - this._cvaOnChange = fn; - } - - /** - * Implemented as part of `ControlValueAccessor` - * @docs-private - */ - registerOnTouched(fn: () => void) { - this._cvaOnTouch = fn; - } - - /** - * Implemented as part of `ControlValueAccessor` - * @docs-private - */ - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - this._changeDetectorRef.markForCheck(); - } - - /** - * Implemented as part of `ControlValueAccessor` - * @docs-private - */ - writeValue(value: any) { - this.checked = !!value; - this._changeDetectorRef.markForCheck(); + super( + 'mat-mdc-checkbox-', + elementRef, + changeDetectorRef, + ngZone, + tabIndex, + animationMode, + options, + ); } /** Focuses the checkbox. */ focus() { - this._nativeCheckbox.nativeElement.focus(); - } - - /** Toggles the `checked` state of the checkbox. */ - toggle() { - this.checked = !this.checked; - this._cvaOnChange(this.checked); - } - - /** Handles blur events on the native input. */ - _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._cvaOnTouch(); - this._changeDetectorRef.markForCheck(); - }); + this._inputElement.nativeElement.focus(); } - /** - * Handles click events on the native input. - * - * Note: we must listen to the `click` event rather than the `change` event because IE & Edge do - * not actually change the checked state when the user clicks an indeterminate checkbox. By - * listening to `click` instead we can override and normalize the behavior to change the checked - * state like other browsers do. - */ - _onClick() { - const clickAction = this._options?.clickAction; - const checkbox = this._nativeCheckbox.nativeElement; - - if (clickAction === 'noop') { - checkbox.checked = this.checked; - checkbox.indeterminate = this.indeterminate; - return; - } - - if (this.indeterminate && clickAction !== 'check') { - this.indeterminate = false; - // tslint:disable:max-line-length - // We use `Promise.resolve().then` to ensure the same timing as the original `MatCheckbox`: - // https://github.com/angular/components/blob/309d5644aa610ee083c56a823ce7c422988730e8/src/lib/checkbox/checkbox.ts#L381 - // tslint:enable:max-line-length - Promise.resolve().then(() => this.indeterminateChange.next(this.indeterminate)); - } else { - checkbox.indeterminate = this.indeterminate; - } - - this.checked = !this.checked; - this._checkboxFoundation.handleChange(); - - // Dispatch our change event - const newEvent = new MatCheckboxChange(); - newEvent.source = this as any; - newEvent.checked = this.checked; - this._cvaOnChange(this.checked); - this.change.next(newEvent); - - // 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._nativeCheckbox) { - this._nativeCheckbox.nativeElement.checked = this.checked; - } + protected _createChangeEvent(isChecked: boolean) { + const event = new MatCheckboxChange(); + event.source = this; + event.checked = isChecked; + return event; } - /** Gets the value for the `aria-checked` attribute of the native input. */ - _getAriaChecked(): 'true' | 'false' | 'mixed' { - if (this.checked) { - return 'true'; - } - - return this.indeterminate ? 'mixed' : 'false'; + protected _getAnimationTargetElement() { + return this._inputElement?.nativeElement; } - /** - * 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._nativeCheckbox; - if (nativeCheckbox) { - nativeCheckbox.nativeElement.indeterminate = value; - } + _onInputClick() { + super._handleInputClick(); } } diff --git a/src/material/checkbox/checkbox.ts b/src/material/checkbox/checkbox.ts index fbf6687e2c75..928458d1f969 100644 --- a/src/material/checkbox/checkbox.ts +++ b/src/material/checkbox/checkbox.ts @@ -9,7 +9,6 @@ import {FocusableOption, FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import { - AfterViewChecked, Attribute, ChangeDetectionStrategy, ChangeDetectorRef, @@ -25,6 +24,7 @@ import { Output, ViewChild, ViewEncapsulation, + Directive, AfterViewInit, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; @@ -88,7 +88,7 @@ export class MatCheckboxChange { // Boilerplate for applying mixins to MatCheckbox. /** @docs-private */ -const _MatCheckboxBase = mixinTabIndex( +const _MatCheckboxMixinBase = mixinTabIndex( mixinColor( mixinDisableRipple( mixinDisabled( @@ -100,49 +100,37 @@ const _MatCheckboxBase = mixinTabIndex( ), ); -/** - * A material design checkbox component. Supports all of the functionality of an HTML5 checkbox, - * and exposes a similar API. A MatCheckbox can be either checked, unchecked, indeterminate, or - * disabled. Note that all additional accessibility attributes are taken care of by the component, - * so there is no need to provide them yourself. However, if you want to omit a label and still - * have the checkbox be accessible, you may supply an [aria-label] input. - * See: https://material.io/design/components/selection-controls.html - */ -@Component({ - selector: 'mat-checkbox', - templateUrl: 'checkbox.html', - styleUrls: ['checkbox.css'], - exportAs: 'matCheckbox', - host: { - 'class': 'mat-checkbox', - '[id]': 'id', - '[attr.tabindex]': 'null', - '[attr.aria-label]': 'null', - '[attr.aria-labelledby]': 'null', - '[class.mat-checkbox-indeterminate]': 'indeterminate', - '[class.mat-checkbox-checked]': 'checked', - '[class.mat-checkbox-disabled]': 'disabled', - '[class.mat-checkbox-label-before]': 'labelPosition == "before"', - '[class._mat-animation-noopable]': `_animationMode === 'NoopAnimations'`, - }, - providers: [MAT_CHECKBOX_CONTROL_VALUE_ACCESSOR], - inputs: ['disableRipple', 'color', 'tabIndex'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MatCheckbox - extends _MatCheckboxBase +@Directive() +export abstract class _MatCheckboxBase + extends _MatCheckboxMixinBase implements - ControlValueAccessor, AfterViewInit, - AfterViewChecked, - OnDestroy, + ControlValueAccessor, CanColor, CanDisable, HasTabIndex, CanDisableRipple, FocusableOption { + /** Focuses the checkbox. */ + abstract focus(origin?: FocusOrigin): void; + + /** Creates the change event that will be emitted by the checkbox. */ + protected abstract _createChangeEvent(isChecked: boolean): E; + + /** Gets the element on which to add the animation CSS classes. */ + protected abstract _getAnimationTargetElement(): HTMLElement | null; + + /** CSS classes to add when transitioning between the different checkbox states. */ + protected abstract _animationClasses: { + uncheckedToChecked: string; + uncheckedToIndeterminate: string; + checkedToUnchecked: string; + checkedToIndeterminate: string; + indeterminateToChecked: string; + indeterminateToUnchecked: string; + }; + /** * Attached to the aria-label attribute of the host element. In most cases, aria-labelledby will * take precedence so this may be omitted. @@ -157,10 +145,10 @@ export class MatCheckbox /** The 'aria-describedby' attribute is read after the element's label and field type. */ @Input('aria-describedby') ariaDescribedby: string; - private _uniqueId: string = `mat-checkbox-${++nextUniqueId}`; + private _uniqueId: string; /** A unique id for the checkbox input. If none is supplied, it will be auto-generated. */ - @Input() id: string = this._uniqueId; + @Input() id: string; /** Returns the unique id for the visual hidden input. */ get inputId(): string { @@ -184,8 +172,7 @@ export class MatCheckbox @Input() name: string | null = null; /** Event emitted when the checkbox's `checked` value changes. */ - @Output() readonly change: EventEmitter = - new EventEmitter(); + @Output() readonly change: EventEmitter = new EventEmitter(); /** Event emitted when the checkbox's `indeterminate` value changes. */ @Output() readonly indeterminateChange: EventEmitter = new EventEmitter(); @@ -212,50 +199,26 @@ export class MatCheckbox private _controlValueAccessorChangeFn: (value: any) => void = () => {}; constructor( + idPrefix: string, elementRef: ElementRef, - private _changeDetectorRef: ChangeDetectorRef, - private _focusMonitor: FocusMonitor, - private _ngZone: NgZone, - @Attribute('tabindex') tabIndex: string, - @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string, - @Optional() - @Inject(MAT_CHECKBOX_DEFAULT_OPTIONS) - private _options?: MatCheckboxDefaultOptions, + protected _changeDetectorRef: ChangeDetectorRef, + protected _ngZone: NgZone, + tabIndex: string, + public _animationMode?: string, + protected _options?: MatCheckboxDefaultOptions, ) { super(elementRef); this._options = this._options || defaults; this.color = this.defaultColor = this._options.color || defaults.color; this.tabIndex = parseInt(tabIndex) || 0; + this.id = this._uniqueId = `${idPrefix}${++nextUniqueId}`; } ngAfterViewInit() { - this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => { - if (!focusOrigin) { - // 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(); - }); - } - }); - this._syncIndeterminate(this._indeterminate); } - // TODO: Delete next major revision. - ngAfterViewChecked() {} - - ngOnDestroy() { - this._focusMonitor.stopMonitoring(this._elementRef); - } - - /** - * Whether the checkbox is checked. - */ + /** Whether the checkbox is checked. */ @Input() get checked(): boolean { return this._checked; @@ -361,9 +324,9 @@ export class MatCheckbox private _transitionCheckState(newState: TransitionCheckState) { let oldState = this._currentCheckState; - let element: HTMLElement = this._elementRef.nativeElement; + let element = this._getAnimationTargetElement(); - if (oldState === newState) { + if (oldState === newState || !element) { return; } if (this._currentAnimationClass.length > 0) { @@ -384,19 +347,15 @@ export class MatCheckbox this._ngZone.runOutsideAngular(() => { setTimeout(() => { - element.classList.remove(animationClass); + element!.classList.remove(animationClass); }, 1000); }); } } private _emitChangeEvent() { - const event = new MatCheckboxChange(); - event.source = this; - event.checked = this.checked; - this._controlValueAccessorChangeFn(this.checked); - this.change.emit(event); + 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. @@ -411,25 +370,9 @@ export class MatCheckbox this._controlValueAccessorChangeFn(this.checked); } - /** - * Event handler for checkbox input element. - * Toggles checked state if element is not disabled. - * Do not toggle on (change) event since IE doesn't fire change event when - * indeterminate checkbox is clicked. - * @param event - */ - _onInputClick(event: Event) { + protected _handleInputClick() { const clickAction = this._options?.clickAction; - // We have to stop propagation for click events on the visual hidden input element. - // By default, when a user clicks on a label element, a generated click event will be - // dispatched on the associated input element. Since we are using a label element as our - // root container, the click event on the `checkbox` will be executed twice. - // The real click event will bubble up, and the generated click event also tries to bubble up. - // This will lead to multiple click events. - // Preventing bubbling for the second event will solve that issue. - event.stopPropagation(); - // 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. @@ -457,15 +400,6 @@ export class MatCheckbox } } - /** Focuses the checkbox. */ - focus(origin?: FocusOrigin, options?: FocusOptions): void { - if (origin) { - this._focusMonitor.focusVia(this._inputElement, origin, options); - } else { - this._inputElement.nativeElement.focus(options); - } - } - _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 @@ -473,6 +407,18 @@ export class MatCheckbox 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, @@ -482,41 +428,31 @@ export class MatCheckbox return ''; } - let animSuffix: string = ''; - 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) { - animSuffix = 'unchecked-checked'; + return this._animationClasses.uncheckedToChecked; } else if (newState == TransitionCheckState.Indeterminate) { - animSuffix = 'unchecked-indeterminate'; - } else { - return ''; + return this._animationClasses.uncheckedToIndeterminate; } break; case TransitionCheckState.Unchecked: - animSuffix = - newState === TransitionCheckState.Checked - ? 'unchecked-checked' - : 'unchecked-indeterminate'; - break; + return newState === TransitionCheckState.Checked + ? this._animationClasses.uncheckedToChecked + : this._animationClasses.uncheckedToIndeterminate; case TransitionCheckState.Checked: - animSuffix = - newState === TransitionCheckState.Unchecked - ? 'checked-unchecked' - : 'checked-indeterminate'; - break; + return newState === TransitionCheckState.Unchecked + ? this._animationClasses.checkedToUnchecked + : this._animationClasses.checkedToIndeterminate; case TransitionCheckState.Indeterminate: - animSuffix = - newState === TransitionCheckState.Checked - ? 'indeterminate-checked' - : 'indeterminate-unchecked'; - break; + return newState === TransitionCheckState.Checked + ? this._animationClasses.indeterminateToChecked + : this._animationClasses.indeterminateToUnchecked; } - return `mat-checkbox-anim-${animSuffix}`; + return ''; } /** @@ -535,3 +471,114 @@ export class MatCheckbox } } } + +/** + * A material design checkbox component. Supports all of the functionality of an HTML5 checkbox, + * and exposes a similar API. A MatCheckbox can be either checked, unchecked, indeterminate, or + * disabled. Note that all additional accessibility attributes are taken care of by the component, + * so there is no need to provide them yourself. However, if you want to omit a label and still + * have the checkbox be accessible, you may supply an [aria-label] input. + * See: https://material.io/design/components/selection-controls.html + */ +@Component({ + selector: 'mat-checkbox', + templateUrl: 'checkbox.html', + styleUrls: ['checkbox.css'], + exportAs: 'matCheckbox', + host: { + 'class': 'mat-checkbox', + '[id]': 'id', + '[attr.tabindex]': 'null', + '[attr.aria-label]': 'null', + '[attr.aria-labelledby]': 'null', + '[class.mat-checkbox-indeterminate]': 'indeterminate', + '[class.mat-checkbox-checked]': 'checked', + '[class.mat-checkbox-disabled]': 'disabled', + '[class.mat-checkbox-label-before]': 'labelPosition == "before"', + '[class._mat-animation-noopable]': `_animationMode === 'NoopAnimations'`, + }, + providers: [MAT_CHECKBOX_CONTROL_VALUE_ACCESSOR], + inputs: ['disableRipple', 'color', 'tabIndex'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MatCheckbox + extends _MatCheckboxBase + implements AfterViewInit, OnDestroy +{ + protected _animationClasses = { + uncheckedToChecked: 'mat-checkbox-anim-unchecked-checked', + uncheckedToIndeterminate: 'mat-checkbox-anim-unchecked-indeterminate', + checkedToUnchecked: 'mat-checkbox-anim-checked-unchecked', + checkedToIndeterminate: 'mat-checkbox-anim-checked-indeterminate', + indeterminateToChecked: 'mat-checkbox-anim-indeterminate-checked', + indeterminateToUnchecked: 'mat-checkbox-anim-indeterminate-unchecked', + }; + + constructor( + elementRef: ElementRef, + changeDetectorRef: ChangeDetectorRef, + private _focusMonitor: FocusMonitor, + ngZone: NgZone, + @Attribute('tabindex') tabIndex: string, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, + @Optional() + @Inject(MAT_CHECKBOX_DEFAULT_OPTIONS) + options?: MatCheckboxDefaultOptions, + ) { + super('mat-checkbox-', elementRef, changeDetectorRef, ngZone, tabIndex, animationMode, options); + } + + protected _createChangeEvent(isChecked: boolean) { + const event = new MatCheckboxChange(); + event.source = this; + event.checked = isChecked; + return event; + } + + protected _getAnimationTargetElement() { + return this._elementRef.nativeElement; + } + + override ngAfterViewInit() { + super.ngAfterViewInit(); + + this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => { + if (!focusOrigin) { + this._onBlur(); + } + }); + } + + ngOnDestroy() { + this._focusMonitor.stopMonitoring(this._elementRef); + } + + /** + * Event handler for checkbox input element. + * Toggles checked state if element is not disabled. + * Do not toggle on (change) event since IE doesn't fire change event when + * indeterminate checkbox is clicked. + * @param event + */ + _onInputClick(event: Event) { + // We have to stop propagation for click events on the visual hidden input element. + // By default, when a user clicks on a label element, a generated click event will be + // dispatched on the associated input element. Since we are using a label element as our + // root container, the click event on the `checkbox` will be executed twice. + // The real click event will bubble up, and the generated click event also tries to bubble up. + // This will lead to multiple click events. + // Preventing bubbling for the second event will solve that issue. + event.stopPropagation(); + super._handleInputClick(); + } + + /** Focuses the checkbox. */ + focus(origin?: FocusOrigin, options?: FocusOptions): void { + if (origin) { + this._focusMonitor.focusVia(this._inputElement, origin, options); + } else { + this._inputElement.nativeElement.focus(options); + } + } +} diff --git a/tools/public_api_guard/material/checkbox.md b/tools/public_api_guard/material/checkbox.md index 7cf81c1e2fed..6e3e94815d76 100644 --- a/tools/public_api_guard/material/checkbox.md +++ b/tools/public_api_guard/material/checkbox.md @@ -5,7 +5,6 @@ ```ts import { _AbstractConstructor } from '@angular/material/core'; -import { AfterViewChecked } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import { BooleanInput } from '@angular/cdk/coercion'; import { CanColor } from '@angular/material/core'; @@ -44,21 +43,63 @@ export function MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY(): MatCheckboxDefaultOption export const MAT_CHECKBOX_REQUIRED_VALIDATOR: Provider; // @public -export class MatCheckbox extends _MatCheckboxBase implements ControlValueAccessor, AfterViewInit, AfterViewChecked, OnDestroy, CanColor, CanDisable, HasTabIndex, CanDisableRipple, FocusableOption { - constructor(elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, _focusMonitor: FocusMonitor, _ngZone: NgZone, tabIndex: string, _animationMode?: string | undefined, _options?: MatCheckboxDefaultOptions | undefined); +export class MatCheckbox extends _MatCheckboxBase implements AfterViewInit, OnDestroy { + constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, _focusMonitor: FocusMonitor, ngZone: NgZone, tabIndex: string, animationMode?: string, options?: MatCheckboxDefaultOptions); + // (undocumented) + protected _animationClasses: { + uncheckedToChecked: string; + uncheckedToIndeterminate: string; + checkedToUnchecked: string; + checkedToIndeterminate: string; + indeterminateToChecked: string; + indeterminateToUnchecked: string; + }; + // (undocumented) + protected _createChangeEvent(isChecked: boolean): MatCheckboxChange; + focus(origin?: FocusOrigin, options?: FocusOptions): void; + // (undocumented) + protected _getAnimationTargetElement(): any; + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + ngOnDestroy(): void; + _onInputClick(event: Event): void; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export abstract class _MatCheckboxBase extends _MatCheckboxMixinBase implements AfterViewInit, ControlValueAccessor, CanColor, CanDisable, HasTabIndex, CanDisableRipple, FocusableOption { + constructor(idPrefix: string, elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, _ngZone: NgZone, tabIndex: string, _animationMode?: string | undefined, _options?: MatCheckboxDefaultOptions | undefined); + protected abstract _animationClasses: { + uncheckedToChecked: string; + uncheckedToIndeterminate: string; + checkedToUnchecked: string; + checkedToIndeterminate: string; + indeterminateToChecked: string; + indeterminateToUnchecked: string; + }; // (undocumented) _animationMode?: string | undefined; ariaDescribedby: string; ariaLabel: string; ariaLabelledby: string | null; - readonly change: EventEmitter; + readonly change: EventEmitter; + // (undocumented) + protected _changeDetectorRef: ChangeDetectorRef; get checked(): boolean; set checked(value: BooleanInput); + protected abstract _createChangeEvent(isChecked: boolean): E; get disabled(): boolean; set disabled(value: BooleanInput); - focus(origin?: FocusOrigin, options?: FocusOptions): void; + abstract focus(origin?: FocusOrigin): void; + protected abstract _getAnimationTargetElement(): HTMLElement | null; // (undocumented) _getAriaChecked(): 'true' | 'false' | 'mixed'; + // (undocumented) + protected _handleInputClick(): void; id: string; get indeterminate(): boolean; set indeterminate(value: BooleanInput); @@ -70,17 +111,18 @@ export class MatCheckbox extends _MatCheckboxBase implements ControlValueAccesso labelPosition: 'before' | 'after'; name: string | null; // (undocumented) - ngAfterViewChecked(): void; - // (undocumented) ngAfterViewInit(): void; // (undocumented) - ngOnDestroy(): void; - _onInputClick(event: Event): void; + protected _ngZone: NgZone; + // (undocumented) + _onBlur(): void; // (undocumented) _onInteractionEvent(event: Event): void; _onLabelTextChange(): void; _onTouched: () => any; // (undocumented) + protected _options?: MatCheckboxDefaultOptions | undefined; + // (undocumented) registerOnChange(fn: (value: any) => void): void; // (undocumented) registerOnTouched(fn: any): void; @@ -94,9 +136,9 @@ export class MatCheckbox extends _MatCheckboxBase implements ControlValueAccesso // (undocumented) writeValue(value: any): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration<_MatCheckboxBase, never, never, { "ariaLabel": "aria-label"; "ariaLabelledby": "aria-labelledby"; "ariaDescribedby": "aria-describedby"; "id": "id"; "required": "required"; "labelPosition": "labelPosition"; "name": "name"; "value": "value"; "checked": "checked"; "disabled": "disabled"; "indeterminate": "indeterminate"; }, { "change": "change"; "indeterminateChange": "indeterminateChange"; }, never, never, false>; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration<_MatCheckboxBase, never>; } // @public