diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index 33ff49f3c389..7e4ffac77e73 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -6,6 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ +import {A11yModule} from '@angular/cdk/a11y'; +import {CdkAccordionModule} from '@angular/cdk/accordion'; +import {BidiModule} from '@angular/cdk/bidi'; +import {ObserversModule} from '@angular/cdk/observers'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {PlatformModule} from '@angular/cdk/platform'; +import {PortalModule} from '@angular/cdk/portal'; +import {CdkTableModule} from '@angular/cdk/table'; import {NgModule} from '@angular/core'; import { MatAutocompleteModule, @@ -24,31 +32,24 @@ import { MatInputModule, MatListModule, MatMenuModule, + MatNativeDateModule, MatPaginatorModule, MatProgressBarModule, MatProgressSpinnerModule, MatRadioModule, + MatRippleModule, MatSelectModule, MatSidenavModule, MatSliderModule, MatSlideToggleModule, MatSnackBarModule, MatSortModule, + MatStepperModule, MatTableModule, MatTabsModule, MatToolbarModule, MatTooltipModule, - MatStepperModule, } from '@angular/material'; -import {MatNativeDateModule, MatRippleModule} from '@angular/material'; -import {CdkTableModule} from '@angular/cdk/table'; -import {CdkAccordionModule} from '@angular/cdk/accordion'; -import {A11yModule} from '@angular/cdk/a11y'; -import {BidiModule} from '@angular/cdk/bidi'; -import {OverlayModule} from '@angular/cdk/overlay'; -import {PlatformModule} from '@angular/cdk/platform'; -import {ObserversModule} from '@angular/cdk/observers'; -import {PortalModule} from '@angular/cdk/portal'; /** * NgModule that includes all Material modules that are required to serve the demo-app. diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index 2703447a2a52..d538ad3c9194 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -631,3 +631,21 @@

<textarea> with ngModel

+ + + Autofill + +
+ + Use custom autofill style + + + Autofill monitored + + + + is autofilled? {{isAutofilled ? 'yes' : 'no'}} +
+
+
diff --git a/src/demo-app/input/input-demo.scss b/src/demo-app/input/input-demo.scss index d7761343e44b..a15c23b80af5 100644 --- a/src/demo-app/input/input-demo.scss +++ b/src/demo-app/input/input-demo.scss @@ -1,3 +1,5 @@ +@import '../../../dist/packages/material/input/autofill'; + .demo-basic { padding: 0; } @@ -27,3 +29,7 @@ padding: 0; background: lightblue; } + +.demo-custom-autofill-style { + @include mat-input-autofill-color(transparent, red); +} diff --git a/src/demo-app/input/input-demo.ts b/src/demo-app/input/input-demo.ts index 3e6839b15e5b..e8989603029b 100644 --- a/src/demo-app/input/input-demo.ts +++ b/src/demo-app/input/input-demo.ts @@ -51,6 +51,8 @@ export class InputDemo { emailFormControl = new FormControl('', [Validators.required, Validators.pattern(EMAIL_REGEX)]); delayedFormControl = new FormControl(''); model = 'hello'; + isAutofilled = false; + customAutofillStyle = true; legacyAppearance: string; standardAppearance: string; diff --git a/src/lib/core/_core.scss b/src/lib/core/_core.scss index ae78d5e77828..748e3a5735b7 100644 --- a/src/lib/core/_core.scss +++ b/src/lib/core/_core.scss @@ -2,6 +2,7 @@ // up into a single flat scss file for material. @import '../../cdk/overlay/overlay'; @import '../../cdk/a11y/a11y'; +@import '../input/autofill'; // Core styles that can be used to apply material design treatments to any element. @import 'style/elevation'; @@ -26,6 +27,7 @@ @include mat-ripple(); @include cdk-a11y(); @include cdk-overlay(); + @include mat-input-autofill(); } // Mixin that renders all of the core styles that depend on the theme. diff --git a/src/lib/input/BUILD.bazel b/src/lib/input/BUILD.bazel index 6bd7b7bbe66f..413c7d8606f7 100644 --- a/src/lib/input/BUILD.bazel +++ b/src/lib/input/BUILD.bazel @@ -8,6 +8,7 @@ ng_module( srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]), module_name = "@angular/material/input", deps = [ + "@rxjs", "//src/lib/core", "//src/lib/form-field", "//src/cdk/coercion", diff --git a/src/lib/input/_autofill.scss b/src/lib/input/_autofill.scss new file mode 100644 index 000000000000..fc778c84a9eb --- /dev/null +++ b/src/lib/input/_autofill.scss @@ -0,0 +1,43 @@ +// Core styles that enable monitoring autofill state of inputs. +@mixin mat-input-autofill { + // Keyframes that apply no styles, but allow us to monitor when an input becomes autofilled + // by watching for the animation events that are fired when they start. + // Based on: https://medium.com/@brunn/detecting-autofilled-fields-in-javascript-aed598d25da7 + @keyframes mat-input-autofill-start {} + @keyframes mat-input-autofill-end {} + + .mat-input-autofill-monitored:-webkit-autofill { + animation-name: mat-input-autofill-start; + } + + .mat-input-autofill-monitored:not(:-webkit-autofill) { + animation-name: mat-input-autofill-end; + } +} + +// Used to generate UIDs for keyframes used to change the input autofill styles. +$mat-input-autofill-color-frame-count: 0; + +// Mixin used to apply custom background and foreground colors to an autofilled input. Based on: +// https://stackoverflow.com/questions/2781549/ +// removing-input-background-colour-for-chrome-autocomplete#answer-37432260 +@mixin mat-input-autofill-color($background, $foreground:'') { + @keyframes mat-input-autofill-color-#{$mat-input-autofill-color-frame-count} { + to { + background: $background; + @if $foreground != '' { color: $foreground; } + } + } + + &:-webkit-autofill { + animation-name: mat-input-autofill-color-#{$mat-input-autofill-color-frame-count}; + animation-fill-mode: both; + } + + &.mat-input-autofill-monitored:-webkit-autofill { + animation-name: mat-input-autofill-start, + mat-input-autofill-color-#{$mat-input-autofill-color-frame-count}; + } + + $mat-input-autofill-color-frame-count: $mat-input-autofill-color-frame-count + 1 !global; +} diff --git a/src/lib/input/autofill-prebuilt.scss b/src/lib/input/autofill-prebuilt.scss new file mode 100644 index 000000000000..39ae312c13f3 --- /dev/null +++ b/src/lib/input/autofill-prebuilt.scss @@ -0,0 +1,3 @@ +@import 'autofill'; + +@include mat-input-autofill(); diff --git a/src/lib/input/autofill.spec.ts b/src/lib/input/autofill.spec.ts new file mode 100644 index 000000000000..f175ff4efa70 --- /dev/null +++ b/src/lib/input/autofill.spec.ts @@ -0,0 +1,192 @@ +/** + * @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.io/license + */ + +import {supportsPassiveEventListeners} from '@angular/cdk/platform'; +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {ComponentFixture, inject, TestBed} from '@angular/core/testing'; +import {empty as observableEmpty} from 'rxjs/observable/empty'; +import {AutofillEvent, AutofillMonitor} from './autofill'; +import {MatInputModule} from './input-module'; + + +const listenerOptions: any = supportsPassiveEventListeners() ? {passive: true} : false; + + +describe('AutofillMonitor', () => { + let autofillMonitor: AutofillMonitor; + let fixture: ComponentFixture; + let testComponent: Inputs; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [MatInputModule], + declarations: [Inputs], + }).compileComponents(); + }); + + beforeEach(inject([AutofillMonitor], (afm: AutofillMonitor) => { + autofillMonitor = afm; + fixture = TestBed.createComponent(Inputs); + testComponent = fixture.componentInstance; + + for (const input of [testComponent.input1, testComponent.input2, testComponent.input3]) { + spyOn(input.nativeElement, 'addEventListener'); + spyOn(input.nativeElement, 'removeEventListener'); + } + + fixture.detectChanges(); + })); + + afterEach(() => { + // Call destroy to make sure we clean up all listeners. + autofillMonitor.ngOnDestroy(); + }); + + it('should add monitored class and listener upon monitoring', () => { + const inputEl = testComponent.input1.nativeElement; + expect(inputEl.addEventListener).not.toHaveBeenCalled(); + + autofillMonitor.monitor(inputEl); + expect(inputEl.classList).toContain('mat-input-autofill-monitored'); + expect(inputEl.addEventListener) + .toHaveBeenCalledWith('animationstart', jasmine.any(Function), listenerOptions); + }); + + it('should not add multiple listeners to the same element', () => { + const inputEl = testComponent.input1.nativeElement; + expect(inputEl.addEventListener).not.toHaveBeenCalled(); + + autofillMonitor.monitor(inputEl); + autofillMonitor.monitor(inputEl); + expect(inputEl.addEventListener).toHaveBeenCalledTimes(1); + }); + + it('should remove monitored class and listener upon stop monitoring', () => { + const inputEl = testComponent.input1.nativeElement; + autofillMonitor.monitor(inputEl); + expect(inputEl.classList).toContain('mat-input-autofill-monitored'); + expect(inputEl.removeEventListener).not.toHaveBeenCalled(); + + autofillMonitor.stopMonitoring(inputEl); + expect(inputEl.classList).not.toContain('mat-input-autofill-monitored'); + expect(inputEl.removeEventListener) + .toHaveBeenCalledWith('animationstart', jasmine.any(Function), listenerOptions); + }); + + it('should stop monitoring all monitored elements upon destroy', () => { + const inputEl1 = testComponent.input1.nativeElement; + const inputEl2 = testComponent.input2.nativeElement; + const inputEl3 = testComponent.input3.nativeElement; + autofillMonitor.monitor(inputEl1); + autofillMonitor.monitor(inputEl2); + autofillMonitor.monitor(inputEl3); + expect(inputEl1.removeEventListener).not.toHaveBeenCalled(); + expect(inputEl2.removeEventListener).not.toHaveBeenCalled(); + expect(inputEl3.removeEventListener).not.toHaveBeenCalled(); + + autofillMonitor.ngOnDestroy(); + expect(inputEl1.removeEventListener).toHaveBeenCalled(); + expect(inputEl2.removeEventListener).toHaveBeenCalled(); + expect(inputEl3.removeEventListener).toHaveBeenCalled(); + }); + + it('should emit and add filled class upon start animation', () => { + const inputEl = testComponent.input1.nativeElement; + let animationStartCallback: Function = () => {}; + let autofillStreamEvent: AutofillEvent | null = null; + inputEl.addEventListener.and.callFake((_, cb) => animationStartCallback = cb); + const autofillStream = autofillMonitor.monitor(inputEl); + autofillStream.subscribe(event => autofillStreamEvent = event); + expect(autofillStreamEvent).toBeNull(); + expect(inputEl.classList).not.toContain('mat-input-autofilled'); + + animationStartCallback({animationName: 'mat-input-autofill-start', target: inputEl}); + expect(inputEl.classList).toContain('mat-input-autofilled'); + expect(autofillStreamEvent).toEqual({target: inputEl, isAutofilled: true} as any); + }); + + it('should emit and remove filled class upon end animation', () => { + const inputEl = testComponent.input1.nativeElement; + let animationStartCallback: Function = () => {}; + let autofillStreamEvent: AutofillEvent | null = null; + inputEl.addEventListener.and.callFake((_, cb) => animationStartCallback = cb); + const autofillStream = autofillMonitor.monitor(inputEl); + autofillStream.subscribe(event => autofillStreamEvent = event); + animationStartCallback({animationName: 'mat-input-autofill-start', target: inputEl}); + expect(inputEl.classList).toContain('mat-input-autofilled'); + expect(autofillStreamEvent).toEqual({target: inputEl, isAutofilled: true} as any); + + animationStartCallback({animationName: 'mat-input-autofill-end', target: inputEl}); + expect(inputEl.classList).not.toContain('mat-input-autofilled'); + expect(autofillStreamEvent).toEqual({target: inputEl, isAutofilled: false} as any); + }); + + it('should cleanup filled class if monitoring stopped in autofilled state', () => { + const inputEl = testComponent.input1.nativeElement; + let animationStartCallback: Function = () => {}; + inputEl.addEventListener.and.callFake((_, cb) => animationStartCallback = cb); + autofillMonitor.monitor(inputEl); + animationStartCallback({animationName: 'mat-input-autofill-start', target: inputEl}); + expect(inputEl.classList).toContain('mat-input-autofilled'); + + autofillMonitor.stopMonitoring(inputEl); + expect(inputEl.classlist).not.toContain('mat-input-autofilled'); + }); +}); + +describe('matAutofill', () => { + let autofillMonitor: AutofillMonitor; + let fixture: ComponentFixture; + let testComponent: InputWithMatAutofilled; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [MatInputModule], + declarations: [InputWithMatAutofilled], + }).compileComponents(); + }); + + beforeEach(inject([AutofillMonitor], (afm: AutofillMonitor) => { + autofillMonitor = afm; + spyOn(autofillMonitor, 'monitor').and.returnValue(observableEmpty()); + spyOn(autofillMonitor, 'stopMonitoring'); + fixture = TestBed.createComponent(InputWithMatAutofilled); + testComponent = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should monitor host element on init', () => { + expect(autofillMonitor.monitor).toHaveBeenCalledWith(testComponent.input.nativeElement); + }); + + it('should stop monitoring host element on destroy', () => { + expect(autofillMonitor.stopMonitoring).not.toHaveBeenCalled(); + fixture.destroy(); + expect(autofillMonitor.stopMonitoring).toHaveBeenCalledWith(testComponent.input.nativeElement); + }); +}); + +@Component({ + template: ` + + + + ` +}) +class Inputs { + @ViewChild('input1') input1: ElementRef; + @ViewChild('input2') input2: ElementRef; + @ViewChild('input3') input3: ElementRef; +} + +@Component({ + template: `` +}) +class InputWithMatAutofilled { + @ViewChild('input') input: ElementRef; +} diff --git a/src/lib/input/autofill.ts b/src/lib/input/autofill.ts new file mode 100644 index 000000000000..84dd4cc5f098 --- /dev/null +++ b/src/lib/input/autofill.ts @@ -0,0 +1,135 @@ +/** + * @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.io/license + */ + +import {Platform, supportsPassiveEventListeners} from '@angular/cdk/platform'; +import { + Directive, + ElementRef, + EventEmitter, + Injectable, + NgZone, + OnDestroy, + Output +} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {empty as observableEmpty} from 'rxjs/observable/empty'; +import {Subject} from 'rxjs/Subject'; + + +/** An event that is emitted when the autofill state of an input changes. */ +export type AutofillEvent = { + /** The element whose autofill state changes. */ + target: Element; + /** Whether the element is currently autofilled. */ + isAutofilled: boolean; +}; + + +/** Used to track info about currently monitored elements. */ +type MonitoredElementInfo = { + subject: Subject; + unlisten: () => void; +}; + + +/** Options to pass to the animationstart listener. */ +const listenerOptions: any = supportsPassiveEventListeners() ? {passive: true} : false; + + +/** + * An injectable service that can be used to monitor the autofill state of an input. + * Based on the following blog post: + * https://medium.com/@brunn/detecting-autofilled-fields-in-javascript-aed598d25da7 + */ +@Injectable() +export class AutofillMonitor implements OnDestroy { + private _monitoredElements = new Map(); + + constructor(private _platform: Platform, private _ngZone: NgZone) {} + + /** + * Monitor for changes in the autofill state of the given input element. + * @param element The element to monitor. + * @return A stream of autofill state changes. + */ + monitor(element: Element): Observable { + if (!this._platform.isBrowser) { + return observableEmpty(); + } + + const info = this._monitoredElements.get(element); + if (info) { + return info.subject.asObservable(); + } + + const result = new Subject(); + const listener = (event: AnimationEvent) => { + if (event.animationName === 'mat-input-autofill-start') { + element.classList.add('mat-input-autofilled'); + result.next({target: event.target as Element, isAutofilled: true}); + } else if (event.animationName === 'mat-input-autofill-end') { + element.classList.remove('mat-input-autofilled'); + result.next({target: event.target as Element, isAutofilled: false}); + } + }; + + this._ngZone.runOutsideAngular(() => { + element.addEventListener('animationstart', listener, listenerOptions); + }); + element.classList.add('mat-input-autofill-monitored'); + + this._monitoredElements.set(element, { + subject: result, + unlisten: () => { + element.removeEventListener('animationstart', listener, listenerOptions); + } + }); + + return result.asObservable(); + } + + /** + * Stop monitoring the autofill state of the given input element. + * @param element The element to stop monitoring. + */ + stopMonitoring(element: Element) { + const info = this._monitoredElements.get(element); + if (info) { + info.unlisten(); + element.classList.remove('mat-input-autofill-monitored'); + element.classList.remove('mat-input-autofilled'); + this._monitoredElements.delete(element); + } + } + + ngOnDestroy() { + this._monitoredElements.forEach(info => { + info.unlisten(); + info.subject.complete(); + }); + } +} + + +/** A directive that can be used to monitor the autofill state of an input. */ +@Directive({ + selector: '[matAutofill]', +}) +export class MatAutofill implements OnDestroy { + @Output() matAutofill = new EventEmitter(); + + constructor(private _elementRef: ElementRef, private _autofillMonitor: AutofillMonitor, + ngZone: NgZone) { + this._autofillMonitor.monitor(this._elementRef.nativeElement) + .subscribe(event => ngZone.run(() => this.matAutofill.emit(event))); + } + + ngOnDestroy() { + this._autofillMonitor.stopMonitoring(this._elementRef.nativeElement); + } +} diff --git a/src/lib/input/input-module.ts b/src/lib/input/input-module.ts index d89a13c3db5b..00f9a838637d 100644 --- a/src/lib/input/input-module.ts +++ b/src/lib/input/input-module.ts @@ -9,14 +9,16 @@ import {PlatformModule} from '@angular/cdk/platform'; import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; +import {ErrorStateMatcher} from '@angular/material/core'; import {MatFormFieldModule} from '@angular/material/form-field'; +import {AutofillMonitor, MatAutofill} from './autofill'; import {MatTextareaAutosize} from './autosize'; import {MatInput} from './input'; -import {ErrorStateMatcher} from '@angular/material/core'; @NgModule({ declarations: [ + MatAutofill, MatInput, MatTextareaAutosize, ], @@ -26,12 +28,13 @@ import {ErrorStateMatcher} from '@angular/material/core'; PlatformModule, ], exports: [ + MatAutofill, // We re-export the `MatFormFieldModule` since `MatInput` will almost always // be used together with `MatFormField`. MatFormFieldModule, MatInput, MatTextareaAutosize, ], - providers: [ErrorStateMatcher], + providers: [ErrorStateMatcher, AutofillMonitor], }) export class MatInputModule {} diff --git a/src/lib/input/public-api.ts b/src/lib/input/public-api.ts index 5eaaf7d50575..f2f80acf31e8 100644 --- a/src/lib/input/public-api.ts +++ b/src/lib/input/public-api.ts @@ -7,9 +7,10 @@ */ -export * from './input-module'; +export * from './autofill'; export * from './autosize'; export * from './input'; export * from './input-errors'; +export * from './input-module'; export * from './input-value-accessor';