Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(input): add utilities for custom styling and monitoring state of input autofill #9719

Merged
merged 6 commits into from
Feb 2, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions src/demo-app/demo-material-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/demo-app/input/input-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -631,3 +631,21 @@ <h3>&lt;textarea&gt; with ngModel</h3>
</tr></table>
</mat-card-content>
</mat-card>

<mat-card class="demo-card demo-basic">
<mat-toolbar color="primary">Autofill</mat-toolbar>
<mat-card-content>
<form novalidate>
<mat-checkbox [(ngModel)]="customAutofillStyle" name="custom">
Use custom autofill style
</mat-checkbox>
<mat-form-field>
<mat-label>Autofill monitored</mat-label>
<input matInput (matAutofill)="isAutofilled = $event.isAutofilled" name="autofill"
[class.demo-custom-autofill-style]="customAutofillStyle">
</mat-form-field>
<button color="primary" mat-raised-button>Submit</button>
<span> is autofilled? {{isAutofilled ? 'yes' : 'no'}}</span>
</form>
</mat-card-content>
</mat-card>
6 changes: 6 additions & 0 deletions src/demo-app/input/input-demo.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import '../../../dist/packages/material/input/autofill';

.demo-basic {
padding: 0;
}
Expand Down Expand Up @@ -27,3 +29,7 @@
padding: 0;
background: lightblue;
}

.demo-custom-autofill-style {
@include mat-input-autofill-color(transparent, red);
}
2 changes: 2 additions & 0 deletions src/demo-app/input/input-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/core/_core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/lib/input/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions src/lib/input/_autofill.scss
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions src/lib/input/autofill-prebuilt.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@import 'autofill';

@include mat-input-autofill();
192 changes: 192 additions & 0 deletions src/lib/input/autofill.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Inputs>;
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<InputWithMatAutofilled>;
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: `
<input #input1>
<input #input2>
<input #input3>
`
})
class Inputs {
@ViewChild('input1') input1: ElementRef;
@ViewChild('input2') input2: ElementRef;
@ViewChild('input3') input3: ElementRef;
}

@Component({
template: `<input #input matAutofill>`
})
class InputWithMatAutofilled {
@ViewChild('input') input: ElementRef;
}
Loading