Skip to content

Commit 78e68e4

Browse files
authored
feat(input): add utilities for custom styling and monitoring state of input autofill (#9719)
* add utility for monitoring input autofill * add scss mixin for styling input autofill colors * tests * move everything from cdk to MatInputModule * address comments * add doc comments
1 parent a0b482b commit 78e68e4

12 files changed

+420
-13
lines changed

src/demo-app/demo-material-module.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {A11yModule} from '@angular/cdk/a11y';
10+
import {CdkAccordionModule} from '@angular/cdk/accordion';
11+
import {BidiModule} from '@angular/cdk/bidi';
12+
import {ObserversModule} from '@angular/cdk/observers';
13+
import {OverlayModule} from '@angular/cdk/overlay';
14+
import {PlatformModule} from '@angular/cdk/platform';
15+
import {PortalModule} from '@angular/cdk/portal';
16+
import {CdkTableModule} from '@angular/cdk/table';
917
import {NgModule} from '@angular/core';
1018
import {
1119
MatAutocompleteModule,
@@ -24,31 +32,24 @@ import {
2432
MatInputModule,
2533
MatListModule,
2634
MatMenuModule,
35+
MatNativeDateModule,
2736
MatPaginatorModule,
2837
MatProgressBarModule,
2938
MatProgressSpinnerModule,
3039
MatRadioModule,
40+
MatRippleModule,
3141
MatSelectModule,
3242
MatSidenavModule,
3343
MatSliderModule,
3444
MatSlideToggleModule,
3545
MatSnackBarModule,
3646
MatSortModule,
47+
MatStepperModule,
3748
MatTableModule,
3849
MatTabsModule,
3950
MatToolbarModule,
4051
MatTooltipModule,
41-
MatStepperModule,
4252
} from '@angular/material';
43-
import {MatNativeDateModule, MatRippleModule} from '@angular/material';
44-
import {CdkTableModule} from '@angular/cdk/table';
45-
import {CdkAccordionModule} from '@angular/cdk/accordion';
46-
import {A11yModule} from '@angular/cdk/a11y';
47-
import {BidiModule} from '@angular/cdk/bidi';
48-
import {OverlayModule} from '@angular/cdk/overlay';
49-
import {PlatformModule} from '@angular/cdk/platform';
50-
import {ObserversModule} from '@angular/cdk/observers';
51-
import {PortalModule} from '@angular/cdk/portal';
5253

5354
/**
5455
* NgModule that includes all Material modules that are required to serve the demo-app.

src/demo-app/input/input-demo.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,3 +631,21 @@ <h3>&lt;textarea&gt; with ngModel</h3>
631631
</tr></table>
632632
</mat-card-content>
633633
</mat-card>
634+
635+
<mat-card class="demo-card demo-basic">
636+
<mat-toolbar color="primary">Autofill</mat-toolbar>
637+
<mat-card-content>
638+
<form novalidate>
639+
<mat-checkbox [(ngModel)]="customAutofillStyle" name="custom">
640+
Use custom autofill style
641+
</mat-checkbox>
642+
<mat-form-field>
643+
<mat-label>Autofill monitored</mat-label>
644+
<input matInput (matAutofill)="isAutofilled = $event.isAutofilled" name="autofill"
645+
[class.demo-custom-autofill-style]="customAutofillStyle">
646+
</mat-form-field>
647+
<button color="primary" mat-raised-button>Submit</button>
648+
<span> is autofilled? {{isAutofilled ? 'yes' : 'no'}}</span>
649+
</form>
650+
</mat-card-content>
651+
</mat-card>

src/demo-app/input/input-demo.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import '../../../dist/packages/material/input/autofill';
2+
13
.demo-basic {
24
padding: 0;
35
}
@@ -27,3 +29,7 @@
2729
padding: 0;
2830
background: lightblue;
2931
}
32+
33+
.demo-custom-autofill-style {
34+
@include mat-input-autofill-color(transparent, red);
35+
}

src/demo-app/input/input-demo.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export class InputDemo {
5151
emailFormControl = new FormControl('', [Validators.required, Validators.pattern(EMAIL_REGEX)]);
5252
delayedFormControl = new FormControl('');
5353
model = 'hello';
54+
isAutofilled = false;
55+
customAutofillStyle = true;
5456

5557
legacyAppearance: string;
5658
standardAppearance: string;

src/lib/core/_core.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// up into a single flat scss file for material.
33
@import '../../cdk/overlay/overlay';
44
@import '../../cdk/a11y/a11y';
5+
@import '../input/autofill';
56

67
// Core styles that can be used to apply material design treatments to any element.
78
@import 'style/elevation';
@@ -26,6 +27,7 @@
2627
@include mat-ripple();
2728
@include cdk-a11y();
2829
@include cdk-overlay();
30+
@include mat-input-autofill();
2931
}
3032

3133
// Mixin that renders all of the core styles that depend on the theme.

src/lib/input/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ ng_module(
88
srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]),
99
module_name = "@angular/material/input",
1010
deps = [
11+
"@rxjs",
1112
"//src/lib/core",
1213
"//src/lib/form-field",
1314
"//src/cdk/coercion",

src/lib/input/_autofill.scss

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Core styles that enable monitoring autofill state of inputs.
2+
@mixin mat-input-autofill {
3+
// Keyframes that apply no styles, but allow us to monitor when an input becomes autofilled
4+
// by watching for the animation events that are fired when they start.
5+
// Based on: https://medium.com/@brunn/detecting-autofilled-fields-in-javascript-aed598d25da7
6+
@keyframes mat-input-autofill-start {}
7+
@keyframes mat-input-autofill-end {}
8+
9+
.mat-input-autofill-monitored:-webkit-autofill {
10+
animation-name: mat-input-autofill-start;
11+
}
12+
13+
.mat-input-autofill-monitored:not(:-webkit-autofill) {
14+
animation-name: mat-input-autofill-end;
15+
}
16+
}
17+
18+
// Used to generate UIDs for keyframes used to change the input autofill styles.
19+
$mat-input-autofill-color-frame-count: 0;
20+
21+
// Mixin used to apply custom background and foreground colors to an autofilled input. Based on:
22+
// https://stackoverflow.com/questions/2781549/
23+
// removing-input-background-colour-for-chrome-autocomplete#answer-37432260
24+
@mixin mat-input-autofill-color($background, $foreground:'') {
25+
@keyframes mat-input-autofill-color-#{$mat-input-autofill-color-frame-count} {
26+
to {
27+
background: $background;
28+
@if $foreground != '' { color: $foreground; }
29+
}
30+
}
31+
32+
&:-webkit-autofill {
33+
animation-name: mat-input-autofill-color-#{$mat-input-autofill-color-frame-count};
34+
animation-fill-mode: both;
35+
}
36+
37+
&.mat-input-autofill-monitored:-webkit-autofill {
38+
animation-name: mat-input-autofill-start,
39+
mat-input-autofill-color-#{$mat-input-autofill-color-frame-count};
40+
}
41+
42+
$mat-input-autofill-color-frame-count: $mat-input-autofill-color-frame-count + 1 !global;
43+
}

src/lib/input/autofill-prebuilt.scss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@import 'autofill';
2+
3+
@include mat-input-autofill();

src/lib/input/autofill.spec.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {supportsPassiveEventListeners} from '@angular/cdk/platform';
10+
import {Component, ElementRef, ViewChild} from '@angular/core';
11+
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
12+
import {empty as observableEmpty} from 'rxjs/observable/empty';
13+
import {AutofillEvent, AutofillMonitor} from './autofill';
14+
import {MatInputModule} from './input-module';
15+
16+
17+
const listenerOptions: any = supportsPassiveEventListeners() ? {passive: true} : false;
18+
19+
20+
describe('AutofillMonitor', () => {
21+
let autofillMonitor: AutofillMonitor;
22+
let fixture: ComponentFixture<Inputs>;
23+
let testComponent: Inputs;
24+
25+
beforeEach(() => {
26+
TestBed.configureTestingModule({
27+
imports: [MatInputModule],
28+
declarations: [Inputs],
29+
}).compileComponents();
30+
});
31+
32+
beforeEach(inject([AutofillMonitor], (afm: AutofillMonitor) => {
33+
autofillMonitor = afm;
34+
fixture = TestBed.createComponent(Inputs);
35+
testComponent = fixture.componentInstance;
36+
37+
for (const input of [testComponent.input1, testComponent.input2, testComponent.input3]) {
38+
spyOn(input.nativeElement, 'addEventListener');
39+
spyOn(input.nativeElement, 'removeEventListener');
40+
}
41+
42+
fixture.detectChanges();
43+
}));
44+
45+
afterEach(() => {
46+
// Call destroy to make sure we clean up all listeners.
47+
autofillMonitor.ngOnDestroy();
48+
});
49+
50+
it('should add monitored class and listener upon monitoring', () => {
51+
const inputEl = testComponent.input1.nativeElement;
52+
expect(inputEl.addEventListener).not.toHaveBeenCalled();
53+
54+
autofillMonitor.monitor(inputEl);
55+
expect(inputEl.classList).toContain('mat-input-autofill-monitored');
56+
expect(inputEl.addEventListener)
57+
.toHaveBeenCalledWith('animationstart', jasmine.any(Function), listenerOptions);
58+
});
59+
60+
it('should not add multiple listeners to the same element', () => {
61+
const inputEl = testComponent.input1.nativeElement;
62+
expect(inputEl.addEventListener).not.toHaveBeenCalled();
63+
64+
autofillMonitor.monitor(inputEl);
65+
autofillMonitor.monitor(inputEl);
66+
expect(inputEl.addEventListener).toHaveBeenCalledTimes(1);
67+
});
68+
69+
it('should remove monitored class and listener upon stop monitoring', () => {
70+
const inputEl = testComponent.input1.nativeElement;
71+
autofillMonitor.monitor(inputEl);
72+
expect(inputEl.classList).toContain('mat-input-autofill-monitored');
73+
expect(inputEl.removeEventListener).not.toHaveBeenCalled();
74+
75+
autofillMonitor.stopMonitoring(inputEl);
76+
expect(inputEl.classList).not.toContain('mat-input-autofill-monitored');
77+
expect(inputEl.removeEventListener)
78+
.toHaveBeenCalledWith('animationstart', jasmine.any(Function), listenerOptions);
79+
});
80+
81+
it('should stop monitoring all monitored elements upon destroy', () => {
82+
const inputEl1 = testComponent.input1.nativeElement;
83+
const inputEl2 = testComponent.input2.nativeElement;
84+
const inputEl3 = testComponent.input3.nativeElement;
85+
autofillMonitor.monitor(inputEl1);
86+
autofillMonitor.monitor(inputEl2);
87+
autofillMonitor.monitor(inputEl3);
88+
expect(inputEl1.removeEventListener).not.toHaveBeenCalled();
89+
expect(inputEl2.removeEventListener).not.toHaveBeenCalled();
90+
expect(inputEl3.removeEventListener).not.toHaveBeenCalled();
91+
92+
autofillMonitor.ngOnDestroy();
93+
expect(inputEl1.removeEventListener).toHaveBeenCalled();
94+
expect(inputEl2.removeEventListener).toHaveBeenCalled();
95+
expect(inputEl3.removeEventListener).toHaveBeenCalled();
96+
});
97+
98+
it('should emit and add filled class upon start animation', () => {
99+
const inputEl = testComponent.input1.nativeElement;
100+
let animationStartCallback: Function = () => {};
101+
let autofillStreamEvent: AutofillEvent | null = null;
102+
inputEl.addEventListener.and.callFake((_, cb) => animationStartCallback = cb);
103+
const autofillStream = autofillMonitor.monitor(inputEl);
104+
autofillStream.subscribe(event => autofillStreamEvent = event);
105+
expect(autofillStreamEvent).toBeNull();
106+
expect(inputEl.classList).not.toContain('mat-input-autofilled');
107+
108+
animationStartCallback({animationName: 'mat-input-autofill-start', target: inputEl});
109+
expect(inputEl.classList).toContain('mat-input-autofilled');
110+
expect(autofillStreamEvent).toEqual({target: inputEl, isAutofilled: true} as any);
111+
});
112+
113+
it('should emit and remove filled class upon end animation', () => {
114+
const inputEl = testComponent.input1.nativeElement;
115+
let animationStartCallback: Function = () => {};
116+
let autofillStreamEvent: AutofillEvent | null = null;
117+
inputEl.addEventListener.and.callFake((_, cb) => animationStartCallback = cb);
118+
const autofillStream = autofillMonitor.monitor(inputEl);
119+
autofillStream.subscribe(event => autofillStreamEvent = event);
120+
animationStartCallback({animationName: 'mat-input-autofill-start', target: inputEl});
121+
expect(inputEl.classList).toContain('mat-input-autofilled');
122+
expect(autofillStreamEvent).toEqual({target: inputEl, isAutofilled: true} as any);
123+
124+
animationStartCallback({animationName: 'mat-input-autofill-end', target: inputEl});
125+
expect(inputEl.classList).not.toContain('mat-input-autofilled');
126+
expect(autofillStreamEvent).toEqual({target: inputEl, isAutofilled: false} as any);
127+
});
128+
129+
it('should cleanup filled class if monitoring stopped in autofilled state', () => {
130+
const inputEl = testComponent.input1.nativeElement;
131+
let animationStartCallback: Function = () => {};
132+
inputEl.addEventListener.and.callFake((_, cb) => animationStartCallback = cb);
133+
autofillMonitor.monitor(inputEl);
134+
animationStartCallback({animationName: 'mat-input-autofill-start', target: inputEl});
135+
expect(inputEl.classList).toContain('mat-input-autofilled');
136+
137+
autofillMonitor.stopMonitoring(inputEl);
138+
expect(inputEl.classlist).not.toContain('mat-input-autofilled');
139+
});
140+
});
141+
142+
describe('matAutofill', () => {
143+
let autofillMonitor: AutofillMonitor;
144+
let fixture: ComponentFixture<InputWithMatAutofilled>;
145+
let testComponent: InputWithMatAutofilled;
146+
147+
beforeEach(() => {
148+
TestBed.configureTestingModule({
149+
imports: [MatInputModule],
150+
declarations: [InputWithMatAutofilled],
151+
}).compileComponents();
152+
});
153+
154+
beforeEach(inject([AutofillMonitor], (afm: AutofillMonitor) => {
155+
autofillMonitor = afm;
156+
spyOn(autofillMonitor, 'monitor').and.returnValue(observableEmpty());
157+
spyOn(autofillMonitor, 'stopMonitoring');
158+
fixture = TestBed.createComponent(InputWithMatAutofilled);
159+
testComponent = fixture.componentInstance;
160+
fixture.detectChanges();
161+
}));
162+
163+
it('should monitor host element on init', () => {
164+
expect(autofillMonitor.monitor).toHaveBeenCalledWith(testComponent.input.nativeElement);
165+
});
166+
167+
it('should stop monitoring host element on destroy', () => {
168+
expect(autofillMonitor.stopMonitoring).not.toHaveBeenCalled();
169+
fixture.destroy();
170+
expect(autofillMonitor.stopMonitoring).toHaveBeenCalledWith(testComponent.input.nativeElement);
171+
});
172+
});
173+
174+
@Component({
175+
template: `
176+
<input #input1>
177+
<input #input2>
178+
<input #input3>
179+
`
180+
})
181+
class Inputs {
182+
@ViewChild('input1') input1: ElementRef;
183+
@ViewChild('input2') input2: ElementRef;
184+
@ViewChild('input3') input3: ElementRef;
185+
}
186+
187+
@Component({
188+
template: `<input #input matAutofill>`
189+
})
190+
class InputWithMatAutofilled {
191+
@ViewChild('input') input: ElementRef;
192+
}

0 commit comments

Comments
 (0)