Skip to content

Commit

Permalink
refactor(material-experimental/mdc-form-field): remove MDC adapter us…
Browse files Browse the repository at this point in the history
…age (#24945)

Refactors the MDC form field so that it doesn't use any of MDC's adapters.

(cherry picked from commit c2eb11d)
  • Loading branch information
crisbeto committed May 20, 2022
1 parent 34dcd42 commit 8c6b221
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 165 deletions.
2 changes: 0 additions & 2 deletions src/material-experimental/mdc-form-field/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ ng_module(
"//src/material-experimental/mdc-core",
"//src/material/form-field",
"@npm//@angular/forms",
"@npm//@material/line-ripple",
"@npm//@material/textfield",
"@npm//rxjs",
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/

import {Directive, ElementRef, Input} from '@angular/core';
import {ponyfill} from '@material/dom';

/**
* Internal directive that maintains a MDC floating label. This directive does not
Expand All @@ -33,15 +32,38 @@ export class MatFormFieldFloatingLabel {
/** Whether the label is floating. */
@Input() floating: boolean = false;

constructor(private _elementRef: ElementRef) {}
constructor(private _elementRef: ElementRef<HTMLElement>) {}

/** Gets the width of the label. Used for the outline notch. */
getWidth(): number {
return ponyfill.estimateScrollWidth(this._elementRef.nativeElement);
return estimateScrollWidth(this._elementRef.nativeElement);
}

/** Gets the HTML element for the floating label. */
get element(): HTMLElement {
return this._elementRef.nativeElement;
}
}

/**
* Estimates the scroll width of an element.
* via https://github.com/material-components/material-components-web/blob/c0a11ef0d000a098fd0c372be8f12d6a99302855/packages/mdc-dom/ponyfill.ts
*/
function estimateScrollWidth(element: HTMLElement): number {
// Check the offsetParent. If the element inherits display: none from any
// parent, the offsetParent property will be null (see
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent).
// This check ensures we only clone the node when necessary.
const htmlEl = element as HTMLElement;
if (htmlEl.offsetParent !== null) {
return htmlEl.scrollWidth;
}

const clone = htmlEl.cloneNode(true) as HTMLElement;
clone.style.setProperty('position', 'absolute');
clone.style.setProperty('transform', 'translate(-9999px, -9999px)');
document.documentElement.appendChild(clone);
const scrollWidth = clone.scrollWidth;
clone.remove();
return scrollWidth;
}
38 changes: 32 additions & 6 deletions src/material-experimental/mdc-form-field/directives/line-ripple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, ElementRef, OnDestroy} from '@angular/core';
import {MDCLineRipple} from '@material/line-ripple';
import {Directive, ElementRef, NgZone, OnDestroy} from '@angular/core';

/** Class added when the line ripple is active. */
const ACTIVATE_CLASS = 'mdc-line-ripple--active';

/** Class added when the line ripple is being deactivated. */
const DEACTIVATING_CLASS = 'mdc-line-ripple--deactivating';

/**
* Internal directive that creates an instance of the MDC line-ripple component. Using a
Expand All @@ -23,12 +28,33 @@ import {MDCLineRipple} from '@material/line-ripple';
'class': 'mdc-line-ripple',
},
})
export class MatFormFieldLineRipple extends MDCLineRipple implements OnDestroy {
constructor(elementRef: ElementRef) {
super(elementRef.nativeElement);
export class MatFormFieldLineRipple implements OnDestroy {
constructor(private _elementRef: ElementRef<HTMLElement>, ngZone: NgZone) {
ngZone.runOutsideAngular(() => {
_elementRef.nativeElement.addEventListener('transitionend', this._handleTransitionEnd);
});
}

activate() {
const classList = this._elementRef.nativeElement.classList;
classList.remove(DEACTIVATING_CLASS);
classList.add(ACTIVATE_CLASS);
}

deactivate() {
this._elementRef.nativeElement.classList.add(DEACTIVATING_CLASS);
}

private _handleTransitionEnd = (event: TransitionEvent) => {
const classList = this._elementRef.nativeElement.classList;
const isDeactivating = classList.contains(DEACTIVATING_CLASS);

if (event.propertyName === 'opacity' && isDeactivating) {
classList.remove(ACTIVATE_CLASS, DEACTIVATING_CLASS);
}
};

ngOnDestroy() {
this.destroy();
this._elementRef.nativeElement.removeEventListener('transitionend', this._handleTransitionEnd);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div class="mdc-notched-outline__leading"></div>
<div class="mdc-notched-outline__notch">
<div class="mdc-notched-outline__notch" [style.width]="_getNotchWidth()">
<ng-content></ng-content>
</div>
<div class="mdc-notched-outline__trailing"></div>
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,18 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Platform} from '@angular/cdk/platform';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnChanges,
OnDestroy,
NgZone,
ViewEncapsulation,
} from '@angular/core';
import {MDCNotchedOutline} from '@material/notched-outline';

/**
* Internal component that creates an instance of the MDC notched-outline component. Using
* a directive allows us to conditionally render a notched-outline in the template without
* having to manually create and destroy the `MDCNotchedOutline` component whenever the
* appearance changes.
* Internal component that creates an instance of the MDC notched-outline component.
*
* The component sets up the HTML structure and styles for the notched-outline. It provides
* inputs to toggle the notch state and width.
Expand All @@ -40,53 +34,37 @@ import {MDCNotchedOutline} from '@material/notched-outline';
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class MatFormFieldNotchedOutline implements AfterViewInit, OnChanges, OnDestroy {
export class MatFormFieldNotchedOutline implements AfterViewInit {
/** Width of the notch. */
@Input('matFormFieldNotchedOutlineWidth') width: number = 0;

/** Whether the notch should be opened. */
@Input('matFormFieldNotchedOutlineOpen') open: boolean = false;

/** Instance of the MDC notched outline. */
private _mdcNotchedOutline: MDCNotchedOutline | null = null;
constructor(private _elementRef: ElementRef<HTMLElement>, private _ngZone: NgZone) {}

constructor(private _elementRef: ElementRef, private _platform: Platform) {}
ngAfterViewInit(): void {
const label = this._elementRef.nativeElement.querySelector<HTMLElement>('.mdc-floating-label');
if (label) {
this._elementRef.nativeElement.classList.add('mdc-notched-outline--upgraded');

ngAfterViewInit() {
// The notched outline cannot be attached in the server platform. It schedules tasks
// for the next browser animation frame and relies on element client rectangles to render
// the outline notch. To avoid failures on the server, we just do not initialize it,
// but the actual notched-outline styles will be still displayed.
if (this._platform.isBrowser) {
// The notch component relies on the view to be initialized. This means
// that we cannot extend from the "MDCNotchedOutline".
this._mdcNotchedOutline = MDCNotchedOutline.attachTo(this._elementRef.nativeElement);
}
// Initial sync in case state has been updated before view initialization.
this._syncNotchedOutlineState();
}

ngOnChanges() {
// Whenever the width, or the open state changes, sync the notched outline to be
// based on the new values.
this._syncNotchedOutlineState();
}

ngOnDestroy() {
if (this._mdcNotchedOutline !== null) {
this._mdcNotchedOutline.destroy();
if (typeof requestAnimationFrame === 'function') {
label.style.transitionDuration = '0s';
this._ngZone.runOutsideAngular(() => {
requestAnimationFrame(() => (label.style.transitionDuration = ''));
});
}
} else {
this._elementRef.nativeElement.classList.add('mdc-notched-outline--no-label');
}
}

/** Synchronizes the notched outline state to be based on the `width` and `open` inputs. */
private _syncNotchedOutlineState() {
if (this._mdcNotchedOutline === null) {
return;
}
_getNotchWidth() {
if (this.open) {
this._mdcNotchedOutline.notch(this.width);
} else {
this._mdcNotchedOutline.closeNotch();
const NOTCH_ELEMENT_PADDING = 8;
return `${this.width > 0 ? this.width + NOTCH_ELEMENT_PADDING : 0}px`;
}

return null;
}
}
121 changes: 11 additions & 110 deletions src/material-experimental/mdc-form-field/form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,6 @@ import {
MatFormFieldControl,
} from '@angular/material/form-field';
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
import {
MDCTextFieldAdapter,
MDCTextFieldFoundation,
numbers as mdcTextFieldNumbers,
} from '@material/textfield';
import {merge, Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
import {MAT_ERROR, MatError} from './directives/error';
Expand Down Expand Up @@ -119,6 +114,9 @@ const FLOATING_LABEL_DEFAULT_DOCKED_TRANSFORM = `translateY(-50%)`;
*/
const WRAPPER_HORIZONTAL_PADDING = 16;

/** Amount by which to scale the label when the form field is focused. */
const LABEL_SCALE = 0.75;

/** Container for form controls that applies Material Design styling and behavior. */
@Component({
selector: 'mat-form-field',
Expand Down Expand Up @@ -278,90 +276,7 @@ export class MatFormField
private _destroyed = new Subject<void>();
private _isFocused: boolean | null = null;
private _explicitFormFieldControl: MatFormFieldControl<any>;
private _foundation: MDCTextFieldFoundation;
private _needsOutlineLabelOffsetUpdateOnStable = false;
private _adapter: MDCTextFieldAdapter = {
addClass: className => this._textField.nativeElement.classList.add(className),
removeClass: className => this._textField.nativeElement.classList.remove(className),
hasClass: className => this._textField.nativeElement.classList.contains(className),

hasLabel: () => this._hasFloatingLabel(),
isFocused: () => this._control.focused,
hasOutline: () => this._hasOutline(),

// MDC text-field will call this method on focus, blur and value change. It expects us
// to update the floating label state accordingly. Though we make this a noop because we
// want to react to floating label state changes through change detection. Relying on this
// adapter method would mean that the label would not update if the custom form field control
// sets "shouldLabelFloat" to true, or if the "floatLabel" input binding changes to "always".
floatLabel: () => {},

// Label shaking is not supported yet. It will require a new API for form field
// controls to trigger the shaking. This can be a feature in the future.
// TODO(devversion): explore options on how to integrate label shaking.
shakeLabel: () => {},

// MDC by default updates the notched-outline whenever the text-field receives focus, or
// is being blurred. It also computes the label width every time the notch is opened or
// closed. This works fine in the standard MDC text-field, but not in Angular where the
// floating label could change through interpolation. We want to be able to update the
// notched outline whenever the label content changes. Additionally, relying on focus or
// blur to open and close the notch does not work for us since abstract form field controls
// have the ability to control the floating label state (i.e. `shouldLabelFloat`), and we
// want to update the notch whenever the `_shouldLabelFloat()` value changes.
getLabelWidth: () => 0,

// We don't use `setLabelRequired` as it relies on a mutation observer for determining
// when the `required` state changes. This is not reliable and flexible enough for
// our form field, as we support custom controls and detect the required state through
// a public property in the abstract form control.
setLabelRequired: () => {},
notchOutline: () => {},
closeOutline: () => {},

activateLineRipple: () => this._lineRipple && this._lineRipple.activate(),
deactivateLineRipple: () => this._lineRipple && this._lineRipple.deactivate(),

// The foundation tries to register events on the input. This is not matching
// our concept of abstract form field controls. We handle each event manually
// in "stateChanges" based on the form field control state. The following events
// need to be handled: focus, blur. We do not handle the "input" event since
// that one is only needed for the text-field character count, which we do
// not implement as part of the form field, but should be implemented manually
// by consumers using template bindings.
registerInputInteractionHandler: () => {},
deregisterInputInteractionHandler: () => {},

// We do not have a reference to the native input since we work with abstract form field
// controls. MDC needs a reference to the native input optionally to handle character
// counting and value updating. These are both things we do not handle from within the
// form field, so we can just return null.
getNativeInput: () => null,

// This method will never be called since we do not have the ability to add event listeners
// to the native input. This is because the form control is not necessarily an input, and
// the form field deals with abstract form controls of any type.
setLineRippleTransformOrigin: () => {},

// The foundation tries to register click and keyboard events on the form field to figure out
// if the input value changes through user interaction. Based on that, the foundation tries
// to focus the input. Since we do not handle the input value as part of the form field, nor
// it's guaranteed to be an input (see adapter methods above), this is a noop.
deregisterTextFieldInteractionHandler: () => {},
registerTextFieldInteractionHandler: () => {},

// The foundation tries to setup a "MutationObserver" in order to watch for attributes
// like "maxlength" or "pattern" to change. The foundation will update the validity state
// based on that. We do not need this logic since we handle the validity through the
// abstract form control instance.
deregisterValidationAttributeChangeHandler: () => {},
registerValidationAttributeChangeHandler: () => null as any,

// Used by foundation to dynamically remove aria-describedby when the hint text
// is shown only on invalid state, which should not be applicable here.
setInputAttr: () => undefined,
removeInputAttr: () => undefined,
};

constructor(
private _elementRef: ElementRef,
Expand All @@ -387,24 +302,6 @@ export class MatFormField
}

ngAfterViewInit() {
this._foundation = new MDCTextFieldFoundation(this._adapter);

// MDC uses the "shouldFloat" getter to know whether the label is currently floating. This
// does not match our implementation of when the label floats because we support more cases.
// For example, consumers can set "@Input floatLabel" to always, or the custom form field
// control can set "MatFormFieldControl#shouldLabelFloat" to true. To ensure that MDC knows
// when the label is floating, we overwrite the property to be based on the method we use to
// determine the current state of the floating label.
Object.defineProperty(this._foundation, 'shouldFloat', {
get: () => this._shouldLabelFloat(),
});

// By default, the foundation determines the validity of the text-field from the
// specified native input. Since we don't pass a native input to the foundation because
// abstract form controls are not necessarily consisting of an input, we handle the
// text-field validity through the abstract form field control state.
this._foundation.isValid = () => !this._control.errorState;

// Initial focus state sync. This happens rarely, but we want to account for
// it in case the form field control has "focused" set to true on init.
this._updateFocusState();
Expand Down Expand Up @@ -445,7 +342,6 @@ export class MatFormField
}

ngOnDestroy() {
this._foundation?.destroy();
this._destroyed.next();
this._destroyed.complete();
}
Expand Down Expand Up @@ -562,11 +458,16 @@ export class MatFormField
// we handle the focus by checking if the abstract form field control focused state changes.
if (this._control.focused && !this._isFocused) {
this._isFocused = true;
this._foundation.activateFocus();
this._lineRipple?.activate();
} else if (!this._control.focused && (this._isFocused || this._isFocused === null)) {
this._isFocused = false;
this._foundation.deactivateFocus();
this._lineRipple?.deactivate();
}

this._textField?.nativeElement.classList.toggle(
'mdc-text-field--focused',
this._control.focused,
);
}

/**
Expand Down Expand Up @@ -652,7 +553,7 @@ export class MatFormField
// The outline notch should be based on the label width, but needs to respect the scaling
// applied to the label if it actively floats. Since the label always floats when the notch
// is open, the MDC text-field floating label scaling is respected in notch width calculation.
this._outlineNotchWidth = this._floatingLabel.getWidth() * mdcTextFieldNumbers.LABEL_SCALE;
this._outlineNotchWidth = this._floatingLabel.getWidth() * LABEL_SCALE;
}

/** Does any extra processing that is required when handling the hints. */
Expand Down

0 comments on commit 8c6b221

Please sign in to comment.