diff --git a/src/demo-app/baseline/baseline-demo.html b/src/demo-app/baseline/baseline-demo.html index f745b02f22cd..e28173d99f94 100644 --- a/src/demo-app/baseline/baseline-demo.html +++ b/src/demo-app/baseline/baseline-demo.html @@ -10,7 +10,11 @@ | Text 3 | Radio 3 | Text 4 | - Label + + | Text 5 | + + + | Text After @@ -28,7 +32,11 @@

| Text 3 | Radio 3 | Text 4 | - Label + + | Text 5 | + + + | Text After

diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 2f692b92d089..0a85b5da9475 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -37,6 +37,7 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab import {ProjectionDemo, ProjectionTestComponent} from './projection/projection-demo'; import {PlatformDemo} from './platform/platform-demo'; import {AutocompleteDemo} from './autocomplete/autocomplete-demo'; +import {InputContainerDemo} from './input/input-container-demo'; @NgModule({ imports: [ @@ -62,6 +63,7 @@ import {AutocompleteDemo} from './autocomplete/autocomplete-demo'; Home, IconDemo, InputDemo, + InputContainerDemo, JazzDialog, ListDemo, LiveAnnouncerDemo, diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 0f5424ee1fa7..5741b0b8888c 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -31,6 +31,7 @@ export class DemoApp { {name: 'Grid List', route: 'grid-list'}, {name: 'Icon', route: 'icon'}, {name: 'Input', route: 'input'}, + {name: 'Input Container', route: 'input-container'}, {name: 'List', route: 'list'}, {name: 'Menu', route: 'menu'}, {name: 'Live Announcer', route: 'live-announcer'}, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 19a7dc1bd42e..41c16f5b5a27 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -32,6 +32,7 @@ import {ProjectionDemo} from '../projection/projection-demo'; import {TABS_DEMO_ROUTES} from '../tabs/routes'; import {PlatformDemo} from '../platform/platform-demo'; import {AutocompleteDemo} from '../autocomplete/autocomplete-demo'; +import {InputContainerDemo} from '../input/input-container-demo'; export const DEMO_APP_ROUTES: Routes = [ {path: '', component: Home}, @@ -51,6 +52,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'overlay', component: OverlayDemo}, {path: 'checkbox', component: CheckboxDemo}, {path: 'input', component: InputDemo}, + {path: 'input-container', component: InputContainerDemo}, {path: 'toolbar', component: ToolbarDemo}, {path: 'icon', component: IconDemo}, {path: 'list', component: ListDemo}, diff --git a/src/demo-app/input/input-container-demo.html b/src/demo-app/input/input-container-demo.html new file mode 100644 index 000000000000..27f10de9a6cf --- /dev/null +++ b/src/demo-app/input/input-container-demo.html @@ -0,0 +1,265 @@ + + Basic + +
+ + + + + + + +
+ + + + + + + +
+

+ + + + + + +

+ + + + +
+ + + + + + + + + + + {{postalCode.value.length}} / 5 + +
+
+
+
+ + + Prefix + Suffix + + + + + .00 + + + + + + Divider Colors + +

Input

+ + + + + + + + + + +

Textarea

+ + + + + + + + + +
+
+ + + Hints + +

Input

+

+ + + {{characterCountInputHintExample.value.length}} / 100 + +

+ +

Textarea

+

+ + + {{characterCountTextareaHintExample.value.length}} / 100 + +

+
+
+ + + + Hello  + + + , + how are you? + + +

+ + + + + + +

+

+ + + +

+

+ + + {{input.value.length}} / 100 + +

+

+ + + +

+ +

+ + + + I favorite bold placeholder + + + I also home italic hint labels + + +

+

+ + + {{hintLabelWithCharCount.value.length}} + +

+

+ Check to change the divider color: + + + +

+

+ Check to make required: + + + +

+

+ Check to make floating label: + + + +

+ +

+ + +

Example: 
+ + + + .00 € + +
+ Both: + + + + email +   + + +  @gmail.com + + +

+ +

+ Empty: + +

+
+
+ + + Number Inputs + + + + + + + + + + + + +
Table + + + + +
{{i+1}} + + + + + + {{item.value}}
+
+
+ + + + Textarea Autosize + + +
+ + + +
+
+
diff --git a/src/demo-app/input/input-container-demo.ts b/src/demo-app/input/input-container-demo.ts new file mode 100644 index 000000000000..cdc3e742064c --- /dev/null +++ b/src/demo-app/input/input-container-demo.ts @@ -0,0 +1,31 @@ +import {Component} from '@angular/core'; + + +let max = 5; + +@Component({ + moduleId: module.id, + selector: 'input-container-demo', + templateUrl: 'input-container-demo.html', + styleUrls: ['input-demo.css'], +}) +export class InputContainerDemo { + dividerColor: boolean; + requiredField: boolean; + floatingLabel: boolean; + name: string; + items: any[] = [ + { value: 10 }, + { value: 20 }, + { value: 30 }, + { value: 40 }, + { value: 50 }, + ]; + rows = 8; + + addABunch(n: number) { + for (let x = 0; x < n; x++) { + this.items.push({ value: ++max }); + } + } +} diff --git a/src/demo-app/platform/platform-demo.html b/src/demo-app/platform/platform-demo.html new file mode 100644 index 000000000000..d6fe9dbc49e6 --- /dev/null +++ b/src/demo-app/platform/platform-demo.html @@ -0,0 +1,12 @@ +

Is Android: {{ platform.ANDROID }}

+

Is iOS: {{ platform.IOS }}

+

Is Firefox: {{ platform.FIREFOX }}

+

Is Blink: {{ platform.BLINK }}

+

Is Webkit: {{ platform.WEBKIT }}

+

Is Trident: {{ platform.TRIDENT }}

+

Is Edge: {{ platform.EDGE }}

+ +

+ Supported input types: + {{ type }}, +

diff --git a/src/demo-app/platform/platform-demo.ts b/src/demo-app/platform/platform-demo.ts index c5b746a70fe5..227b0d135aea 100644 --- a/src/demo-app/platform/platform-demo.ts +++ b/src/demo-app/platform/platform-demo.ts @@ -1,17 +1,14 @@ import {Component} from '@angular/core'; -import {MdPlatform} from '@angular/material'; +import {MdPlatform, getSupportedInputTypes} from '@angular/material'; + @Component({ - template: ` -

Is Android: {{ platform.ANDROID }}

-

Is iOS: {{ platform.IOS }}

-

Is Firefox: {{ platform.FIREFOX }}

-

Is Blink: {{ platform.BLINK }}

-

Is Webkit: {{ platform.WEBKIT }}

-

Is Trident: {{ platform.TRIDENT }}

-

Is Edge: {{ platform.EDGE }}

- ` + moduleId: module.id, + selector: 'platform-demo', + templateUrl: 'platform-demo.html', }) export class PlatformDemo { + supportedInputTypes = getSupportedInputTypes(); + constructor(public platform: MdPlatform) {} } diff --git a/src/demo-app/ripple/ripple-demo.html b/src/demo-app/ripple/ripple-demo.html index b2bb986c0df3..75ddda95e698 100644 --- a/src/demo-app/ripple/ripple-demo.html +++ b/src/demo-app/ripple/ripple-demo.html @@ -27,9 +27,18 @@
- - - + + + + + + + + +
diff --git a/src/demo-app/snack-bar/snack-bar-demo.html b/src/demo-app/snack-bar/snack-bar-demo.html index faa23b684de6..bbfefec80e4c 100644 --- a/src/demo-app/snack-bar/snack-bar-demo.html +++ b/src/demo-app/snack-bar/snack-bar-demo.html @@ -1,24 +1,32 @@

SnackBar demo

-
Message:
+
+ Message: +

Show button on snack bar

- + + +

Auto hide after duration

- + + +
- \ No newline at end of file + diff --git a/src/demo-app/tabs/tabs-demo.html b/src/demo-app/tabs/tabs-demo.html index 6950c40864d0..fb9826927a7f 100644 --- a/src/demo-app/tabs/tabs-demo.html +++ b/src/demo-app/tabs/tabs-demo.html @@ -127,7 +127,9 @@

Tab Group Demo - Dynamic Height



- + + + @@ -169,7 +171,9 @@

Tab Group Demo - Fixed Height



- + + + @@ -192,7 +196,9 @@

Async Tabs




- + + + @@ -205,4 +211,4 @@

Tabs with simplified api

This tab is about combustion! - \ No newline at end of file + diff --git a/src/demo-app/tooltip/tooltip-demo.html b/src/demo-app/tooltip/tooltip-demo.html index 6da3a8b8efc6..bba9c3fa3f8b 100644 --- a/src/demo-app/tooltip/tooltip-demo.html +++ b/src/demo-app/tooltip/tooltip-demo.html @@ -24,7 +24,7 @@

Tooltip Demo

Message: - +

Mouse over to diff --git a/src/lib/core/a11y/index.ts b/src/lib/core/a11y/index.ts index 0be8e05abeb6..4c1955615510 100644 --- a/src/lib/core/a11y/index.ts +++ b/src/lib/core/a11y/index.ts @@ -3,7 +3,7 @@ import {FocusTrap} from './focus-trap'; import {MdLiveAnnouncer} from './live-announcer'; import {InteractivityChecker} from './interactivity-checker'; import {CommonModule} from '@angular/common'; -import {PlatformModule} from '../platform/platform'; +import {PlatformModule} from '../platform/index'; export const A11Y_PROVIDERS = [MdLiveAnnouncer, InteractivityChecker]; diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index e5127292671d..0791f8842195 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -31,6 +31,7 @@ export * from './projection/projection'; // Platform export * from './platform/platform'; +export * from './platform/features'; // Overlay export {Overlay, OVERLAY_PROVIDERS} from './overlay/overlay'; diff --git a/src/lib/core/platform/features.ts b/src/lib/core/platform/features.ts new file mode 100644 index 000000000000..1f6c62ab0c3f --- /dev/null +++ b/src/lib/core/platform/features.ts @@ -0,0 +1,36 @@ +let supportedInputTypes: Set; + +/** @returns {Set} the input types supported by this browser. */ +export function getSupportedInputTypes(): Set { + if (!supportedInputTypes) { + let featureTestInput = document.createElement('input'); + supportedInputTypes = new Set([ + 'button', + 'checkbox', + 'color', + 'date', + 'datetime-local', + 'email', + 'file', + 'hidden', + 'image', + 'month', + 'number', + 'password', + 'radio', + 'range', + 'reset', + 'search', + 'submit', + 'tel', + 'text', + 'time', + 'url', + 'week', + ].filter(value => { + featureTestInput.setAttribute('type', value); + return featureTestInput.type === value; + })); + } + return supportedInputTypes; +} diff --git a/src/lib/core/platform/index.ts b/src/lib/core/platform/index.ts new file mode 100644 index 000000000000..d7e93942da24 --- /dev/null +++ b/src/lib/core/platform/index.ts @@ -0,0 +1,13 @@ +import {NgModule, ModuleWithProviders} from '@angular/core'; +import {MdPlatform} from './platform'; + + +@NgModule({}) +export class PlatformModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: PlatformModule, + providers: [MdPlatform], + }; + } +} diff --git a/src/lib/core/platform/platform.ts b/src/lib/core/platform/platform.ts index 44477046e4ef..3b2e655c7d13 100644 --- a/src/lib/core/platform/platform.ts +++ b/src/lib/core/platform/platform.ts @@ -1,4 +1,4 @@ -import {Injectable, NgModule, ModuleWithProviders} from '@angular/core'; +import {Injectable} from '@angular/core'; declare const window: any; @@ -12,7 +12,6 @@ const hasV8BreakIterator = (window.Intl && (window.Intl as any).v8BreakIterator) */ @Injectable() export class MdPlatform { - /** Layout Engines */ EDGE = /(edge)/i.test(navigator.userAgent); TRIDENT = /(msie|trident)/i.test(navigator.userAgent); @@ -35,15 +34,4 @@ export class MdPlatform { // Trident on mobile adds the android platform to the userAgent to trick detections. ANDROID = /android/i.test(navigator.userAgent) && !this.TRIDENT; - -} - -@NgModule({}) -export class PlatformModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: PlatformModule, - providers: [MdPlatform], - }; - } } diff --git a/src/lib/input/_input-theme.scss b/src/lib/input/_input-theme.scss index 0c424275b7ee..e4a52a093f54 100644 --- a/src/lib/input/_input-theme.scss +++ b/src/lib/input/_input-theme.scss @@ -39,8 +39,8 @@ } // See md-input-placeholder-floating mixin in input.scss - md-input input:-webkit-autofill + .md-input-placeholder, .md-input-placeholder.md-float.md-focused { - + input.md-input-element:-webkit-autofill + .md-input-placeholder, + .md-input-placeholder.md-float.md-focused { .md-placeholder-required { color: $input-required-placeholder-color; } diff --git a/src/lib/input/index.ts b/src/lib/input/index.ts index c630aefeaf7f..ab131b1f1f40 100644 --- a/src/lib/input/index.ts +++ b/src/lib/input/index.ts @@ -1,2 +1,4 @@ +export * from './autosize' export * from './input'; -export {MdTextareaAutosize} from './autosize'; +export * from './input-container'; +export * from './input-container-errors'; diff --git a/src/lib/input/input-container-errors.ts b/src/lib/input/input-container-errors.ts new file mode 100644 index 000000000000..214997f60f64 --- /dev/null +++ b/src/lib/input/input-container-errors.ts @@ -0,0 +1,22 @@ +import {MdError} from '../core/errors/error'; + + +export class MdInputContainerPlaceholderConflictError extends MdError { + constructor() { + super('Placeholder attribute and child element were both specified.'); + } +} + + +export class MdInputContainerUnsupportedTypeError extends MdError { + constructor(type: string) { + super(`Input type "${type}" isn't supported by md-input-container.`); + } +} + + +export class MdInputContainerDuplicatedHintError extends MdError { + constructor(align: string) { + super(`A hint was already declared for 'align="${align}"'.`); + } +} diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html new file mode 100644 index 000000000000..04f0f8ee1d16 --- /dev/null +++ b/src/lib/input/input-container.html @@ -0,0 +1,35 @@ +
+
+
+ +
+ + + +
+ +
+
+ +
+ +
+ +
{{hintLabel}}
+ +
diff --git a/src/lib/input/input-container.scss b/src/lib/input/input-container.scss new file mode 100644 index 000000000000..28c6086e5945 --- /dev/null +++ b/src/lib/input/input-container.scss @@ -0,0 +1,29 @@ +@import '../core/style/variables'; + +md-input-container { + display: inline-block; + position: relative; + font-family: $md-font-family; + line-height: normal; + + // To avoid problems with text-align. + text-align: left; + + [dir='rtl'] & { + text-align: right; + } +} + +.md-input-element { + &::placeholder { + visibility: hidden; + } + + .md-end & { + text-align: right; + + [dir='rtl'] & { + text-align: left; + } + } +} diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts new file mode 100644 index 000000000000..c0c2bd1985c6 --- /dev/null +++ b/src/lib/input/input-container.spec.ts @@ -0,0 +1,408 @@ +import {async, TestBed, inject} from '@angular/core/testing'; +import {Component} from '@angular/core'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {By} from '@angular/platform-browser'; +import {MdInputModule} from './input'; +import {MdInputContainer} from './input-container'; +import {MdPlatform} from '../core/platform/platform'; +import {PlatformModule} from '../core/platform/index'; + + +describe('MdInputContainer', function () { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MdInputModule.forRoot(), + PlatformModule.forRoot(), + FormsModule, + ReactiveFormsModule + ], + declarations: [ + MdInputContainerPlaceholderRequiredTestComponent, + MdInputContainerPlaceholderElementTestComponent, + MdInputContainerPlaceholderAttrTestComponent, + MdInputContainerHintLabel2TestController, + MdInputContainerHintLabelTestController, + MdInputContainerInvalidTypeTestController, + MdInputContainerInvalidPlaceholderTestController, + MdInputContainerInvalidHint2TestController, + MdInputContainerInvalidHintTestController, + MdInputContainerBaseTestController, + MdInputContainerWithId, + MdInputContainerDateTestController, + MdInputContainerTextTestController, + MdInputContainerPasswordTestController, + MdInputContainerNumberTestController, + MdTextareaWithBindings, + MdInputContainerWithDisabled, + ], + }); + + TestBed.compileComponents(); + })); + + it('should default to floating placeholders', () => { + let fixture = TestBed.createComponent(MdInputContainerBaseTestController); + fixture.detectChanges(); + + let inputContainer = fixture.debugElement.query(By.directive(MdInputContainer)) + .componentInstance as MdInputContainer; + expect(inputContainer.floatingPlaceholder).toBe(true, + 'Expected MdInputContainer to default to having floating placeholders turned on'); + }); + + it('should not be treated as empty if type is date', + inject([MdPlatform], (platform: MdPlatform) => { + if (!(platform.TRIDENT || platform.FIREFOX)) { + let fixture = TestBed.createComponent(MdInputContainerDateTestController); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')).nativeElement; + expect(el).not.toBeNull(); + expect(el.classList.contains('md-empty')).toBe(false); + } + })); + + // Firefox and IE don't support type="date" and fallback to type="text". + it('should be treated as empty if type is date on Firefox and IE', + inject([MdPlatform], (platform: MdPlatform) => { + if (platform.TRIDENT || platform.FIREFOX) { + let fixture = TestBed.createComponent(MdInputContainerDateTestController); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')).nativeElement; + expect(el).not.toBeNull(); + expect(el.classList.contains('md-empty')).toBe(true); + } + })); + + it('should treat text input type as empty at init', () => { + let fixture = TestBed.createComponent(MdInputContainerTextTestController); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')).nativeElement; + expect(el).not.toBeNull(); + expect(el.classList.contains('md-empty')).toBe(true); + }); + + it('should treat password input type as empty at init', () => { + let fixture = TestBed.createComponent(MdInputContainerPasswordTestController); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')).nativeElement; + expect(el).not.toBeNull(); + expect(el.classList.contains('md-empty')).toBe(true); + }); + + it('should treat number input type as empty at init', () => { + let fixture = TestBed.createComponent(MdInputContainerNumberTestController); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')).nativeElement; + expect(el).not.toBeNull(); + expect(el.classList.contains('md-empty')).toBe(true); + }); + + it('should not be empty after input entered', async(() => { + let fixture = TestBed.createComponent(MdInputContainerTextTestController); + fixture.detectChanges(); + + let inputEl = fixture.debugElement.query(By.css('input')); + let el = fixture.debugElement.query(By.css('label')).nativeElement; + expect(el).not.toBeNull(); + expect(el.classList.contains('md-empty')).toBe(true, 'should be empty'); + + inputEl.nativeElement.value = 'hello'; + // Simulate input event. + inputEl.triggerEventHandler('input', {target: inputEl.nativeElement}); + fixture.detectChanges(); + + el = fixture.debugElement.query(By.css('label')).nativeElement; + expect(el.classList.contains('md-empty')).toBe(false, 'should not be empty'); + })); + + it('should add id', () => { + let fixture = TestBed.createComponent(MdInputContainerTextTestController); + fixture.detectChanges(); + + const inputElement: HTMLInputElement = + fixture.debugElement.query(By.css('input')).nativeElement; + const labelElement: HTMLInputElement = + fixture.debugElement.query(By.css('label')).nativeElement; + + expect(inputElement.id).toBeTruthy(); + expect(inputElement.id).toEqual(labelElement.getAttribute('for')); + }); + + it('should not overwrite existing id', () => { + let fixture = TestBed.createComponent(MdInputContainerWithId); + fixture.detectChanges(); + + const inputElement: HTMLInputElement = + fixture.debugElement.query(By.css('input')).nativeElement; + const labelElement: HTMLInputElement = + fixture.debugElement.query(By.css('label')).nativeElement; + + expect(inputElement.id).toBe('test-id'); + expect(labelElement.getAttribute('for')).toBe('test-id'); + }); + + it('validates there\'s only one hint label per side', () => { + let fixture = TestBed.createComponent(MdInputContainerInvalidHintTestController); + + expect(() => fixture.detectChanges()).toThrow(); + // TODO(jelbourn): .toThrow(new MdInputContainerDuplicatedHintError('start')); + // See https://github.com/angular/angular/issues/8348 + }); + + it('validates there\'s only one hint label per side (attribute)', () => { + let fixture = TestBed.createComponent(MdInputContainerInvalidHint2TestController); + + expect(() => fixture.detectChanges()).toThrow(); + // TODO(jelbourn): .toThrow(new MdInputContainerDuplicatedHintError('start')); + // See https://github.com/angular/angular/issues/8348 + }); + + it('validates there\'s only one placeholder', () => { + let fixture = TestBed.createComponent(MdInputContainerInvalidPlaceholderTestController); + + expect(() => fixture.detectChanges()).toThrow(); + // TODO(jelbourn): .toThrow(new MdInputContainerPlaceholderConflictError()); + // See https://github.com/angular/angular/issues/8348 + }); + + it('validates the type', () => { + let fixture = TestBed.createComponent(MdInputContainerInvalidTypeTestController); + + // Technically this throws during the OnChanges detection phase, + // so the error is really a ChangeDetectionError and it becomes + // hard to build a full exception to compare with. + // We just check for any exception in this case. + expect(() => fixture.detectChanges()).toThrow( + /* new MdInputContainerUnsupportedTypeError('file') */); + }); + + it('supports hint labels attribute', () => { + let fixture = TestBed.createComponent(MdInputContainerHintLabelTestController); + fixture.detectChanges(); + + // If the hint label is empty, expect no label. + expect(fixture.debugElement.query(By.css('.md-hint'))).toBeNull(); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.md-hint'))).not.toBeNull(); + }); + + it('supports hint labels elements', () => { + let fixture = TestBed.createComponent(MdInputContainerHintLabel2TestController); + fixture.detectChanges(); + + // In this case, we should have an empty . + let el = fixture.debugElement.query(By.css('md-hint')).nativeElement; + expect(el.textContent).toBeFalsy(); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + el = fixture.debugElement.query(By.css('md-hint')).nativeElement; + expect(el.textContent).toBe('label'); + }); + + it('supports placeholder attribute', async(() => { + let fixture = TestBed.createComponent(MdInputContainerPlaceholderAttrTestComponent); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')); + expect(el).toBeNull(); + + fixture.componentInstance.placeholder = 'Other placeholder'; + fixture.detectChanges(); + + el = fixture.debugElement.query(By.css('label')); + expect(el).not.toBeNull(); + expect(el.nativeElement.textContent).toMatch('Other placeholder'); + expect(el.nativeElement.textContent).not.toMatch(/\*/g); + })); + + it('supports placeholder element', async(() => { + let fixture = TestBed.createComponent(MdInputContainerPlaceholderElementTestComponent); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')); + expect(el).not.toBeNull(); + expect(el.nativeElement.textContent).toMatch('Default Placeholder'); + + fixture.componentInstance.placeholder = 'Other placeholder'; + fixture.detectChanges(); + + el = fixture.debugElement.query(By.css('label')); + expect(el).not.toBeNull(); + expect(el.nativeElement.textContent).toMatch('Other placeholder'); + expect(el.nativeElement.textContent).not.toMatch(/\*/g); + })); + + it('supports placeholder required star', () => { + let fixture = TestBed.createComponent(MdInputContainerPlaceholderRequiredTestComponent); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label')); + expect(el).not.toBeNull(); + expect(el.nativeElement.textContent).toMatch(/hello\s+\*/g); + }); + + it('supports the disabled attribute', async(() => { + let fixture = TestBed.createComponent(MdInputContainerWithDisabled); + fixture.detectChanges(); + + let underlineEl = fixture.debugElement.query(By.css('.md-input-underline')).nativeElement; + expect(underlineEl.classList.contains('md-disabled')).toBe(false, 'should not be disabled'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + expect(underlineEl.classList.contains('md-disabled')).toBe(true, 'should be disabled'); + })); + + it('supports textarea', () => { + let fixture = TestBed.createComponent(MdTextareaWithBindings); + fixture.detectChanges(); + + const textarea: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea'); + expect(textarea).not.toBeNull(); + }); +}); + +@Component({ + template: ` + + + ` +}) +class MdInputContainerWithId {} + +@Component({ + template: `` +}) +class MdInputContainerWithDisabled { + disabled: boolean; +} + +@Component({ + template: `` +}) +class MdInputContainerPlaceholderRequiredTestComponent {} + +@Component({ + template: ` + + + {{placeholder}} + ` +}) +class MdInputContainerPlaceholderElementTestComponent { + placeholder: string = 'Default Placeholder'; +} + +@Component({ + template: `` +}) +class MdInputContainerPlaceholderAttrTestComponent { + placeholder: string = ''; +} + +@Component({ + template: `{{label}}` +}) +class MdInputContainerHintLabel2TestController { + label: string = ''; +} + +@Component({ + template: `` +}) +class MdInputContainerHintLabelTestController { + label: string = ''; +} + +@Component({ + template: `` +}) +class MdInputContainerInvalidTypeTestController {} + +@Component({ + template: ` + + + World + ` +}) +class MdInputContainerInvalidPlaceholderTestController {} + +@Component({ + template: ` + + + World + ` +}) +class MdInputContainerInvalidHint2TestController {} + +@Component({ + template: ` + + + Hello + World + ` +}) +class MdInputContainerInvalidHintTestController {} + +@Component({ + template: `` +}) +class MdInputContainerBaseTestController { + model: any = ''; +} + +@Component({ + template: ` + + + ` +}) +class MdInputContainerDateTestController {} + +@Component({ + template: ` + + + ` +}) +class MdInputContainerTextTestController {} + +@Component({ + template: ` + + + ` +}) +class MdInputContainerPasswordTestController {} + +@Component({ + template: ` + + + ` +}) +class MdInputContainerNumberTestController {} + +@Component({ + template: ` + + + ` +}) +class MdTextareaWithBindings { + rows: number = 4; + cols: number = 8; + wrap: string = 'hard'; +} diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts new file mode 100644 index 000000000000..e50645f90f91 --- /dev/null +++ b/src/lib/input/input-container.ts @@ -0,0 +1,266 @@ +import { + Component, + Input, + Directive, + AfterContentInit, + ContentChild, + ContentChildren, + ElementRef, + QueryList, + ViewEncapsulation, + Optional, + Output, + EventEmitter, + Renderer +} from '@angular/core'; +import {coerceBooleanProperty} from '../core'; +import {NgControl} from '@angular/forms'; +import {getSupportedInputTypes} from '../core/platform/features'; +import { + MdInputContainerUnsupportedTypeError, + MdInputContainerPlaceholderConflictError, + MdInputContainerDuplicatedHintError +} from './input-container-errors'; + + +// Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError. +const MD_INPUT_INVALID_TYPES = [ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit' +]; + + +let nextUniqueId = 0; + + +/** + * The placeholder directive. The content can declare this to implement more + * complex placeholders. + */ +@Directive({ + selector: 'md-placeholder, mat-placeholder' +}) +export class MdPlaceholder {} + + +/** The hint directive, used to tag content as hint labels (going under the input). */ +@Directive({ + selector: 'md-hint, mat-hint', + host: { + 'class': 'md-hint', + '[class.md-right]': 'align == "end"', + } +}) +export class MdHint { + // Whether to align the hint label at the start or end of the line. + @Input() align: 'start' | 'end' = 'start'; +} + + +/** The input directive, used to mark the input that `MdInputContainer` is wrapping. */ +@Directive({ + selector: 'input[md-input], textarea[md-input], input[mat-input], textarea[mat-input]', + host: { + 'class': 'md-input-element', + '[id]': 'id', + '(blur)': '_onBlur()', + '(focus)': '_onFocus()', + '(input)': '_onInput()', + } +}) +export class MdInputDirective implements AfterContentInit { + @Input() + get disabled() { return this._disabled; } + set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } + private _disabled = false; + + @Input() + get id() { return this._id; }; + set id(value: string) { this._id = value || this._uid; } + private _id: string; + + @Input() + get placeholder() { return this._placeholder; } + set placeholder(value: string) { + if (this._placeholder != value) { + this._placeholder = value; + this._placeholderChange.emit(this._placeholder); + } + } + private _placeholder = ''; + + @Input() + get required() { return this._required; } + set required(value: any) { this._required = coerceBooleanProperty(value); } + private _required = false; + + @Input() + get type() { return this._type; } + set type(value: string) { + this._type = value || 'text'; + this._validateType(); + } + private _type = 'text'; + + value: any; + + /** + * Emits an event when the placeholder changes so that the `md-input-container` can re-validate. + */ + @Output() _placeholderChange = new EventEmitter(); + + get empty() { return (this.value == null || this.value == '') && !this._isNeverEmpty(); } + + focused = false; + + private get _uid() { return this._cachedUid = this._cachedUid || `md-input-${nextUniqueId++}`; } + private _cachedUid: string; + + private _neverEmptyInputTypes = [ + 'date', + 'datetime', + 'datetime-local', + 'month', + 'time', + 'week' + ].filter(t => getSupportedInputTypes().has(t)); + + constructor(private _elementRef: ElementRef, + private _renderer: Renderer, + @Optional() private _ngControl: NgControl) { + // Force setter to be called in case id was not specified. + this.id = this.id; + + if (this._ngControl) { + this._ngControl.valueChanges.subscribe((value) => { + this.value = value; + }); + } + } + + ngAfterContentInit() { + this.value = this._elementRef.nativeElement.value; + } + + /** Focus the input element. */ + focus() { this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus'); } + + _onFocus() { this.focused = true; } + + _onBlur() { this.focused = false; } + + _onInput() { this.value = this._elementRef.nativeElement.value; } + + /** Make sure the input is a supported type. */ + private _validateType() { + if (MD_INPUT_INVALID_TYPES.indexOf(this._type) != -1) { + throw new MdInputContainerUnsupportedTypeError(this._type); + } + } + + private _isNeverEmpty() { return this._neverEmptyInputTypes.indexOf(this._type) != -1; } +} + + +/** + * Component that represents a text input. It encapsulates the HTMLElement and + * improve on its behaviour, along with styling it according to the Material Design. + */ +@Component({ + moduleId: module.id, + selector: 'md-input-container, mat-input-container', + templateUrl: 'input-container.html', + styleUrls: ['input.css', 'input-container.css'], + host: { + // Remove align attribute to prevent it from interfering with layout. + '[attr.align]': 'null', + '(click)': '_focusInput()', + }, + encapsulation: ViewEncapsulation.None, +}) +export class MdInputContainer implements AfterContentInit { + @Input() align: 'start' | 'end' = 'start'; + + @Input() dividerColor: 'primary' | 'accent' | 'warn' = 'primary'; + + @Input() + get hintLabel() { return this._hintLabel; } + set hintLabel(value: string) { + this._hintLabel = value; + this._validateHints(); + } + private _hintLabel = ''; + + @Input() + get floatingPlaceholder(): boolean { return this._floatingPlaceholder; } + set floatingPlaceholder(value) { this._floatingPlaceholder = coerceBooleanProperty(value); } + private _floatingPlaceholder: boolean = true; + + @ContentChild(MdInputDirective) _mdInputChild: MdInputDirective; + + @ContentChild(MdPlaceholder) _placeholderChild: MdPlaceholder; + + @ContentChildren(MdHint) _hintChildren: QueryList; + + ngAfterContentInit() { + this._validateHints(); + this._validatePlaceholders(); + + // Re-validate when things change. + this._hintChildren.changes.subscribe(() => { + this._validateHints(); + }); + this._mdInputChild._placeholderChange.subscribe(() => { + this._validatePlaceholders(); + }); + } + + /** Whether the input has a placeholder. */ + _hasPlaceholder(): boolean { + return !!this._mdInputChild.placeholder || !!this._placeholderChild; + } + + _focusInput() { this._mdInputChild.focus(); } + + /** + * Ensure that there is only one placeholder (either `input` attribute or child element with the + * `md-placeholder` attribute. + */ + private _validatePlaceholders() { + if (this._mdInputChild.placeholder && this._placeholderChild) { + throw new MdInputContainerPlaceholderConflictError(); + } + } + + /** + * Ensure that there is a maximum of one of each `` alignment specified, with the + * attribute being considered as `align="start"`. + */ + private _validateHints() { + if (this._hintChildren) { + let startHint: MdHint = null; + let endHint: MdHint = null; + this._hintChildren.forEach((hint: MdHint) => { + if (hint.align == 'start') { + if (startHint || this.hintLabel) { + throw new MdInputContainerDuplicatedHintError('start'); + } + startHint = hint; + } else if (hint.align == 'end') { + if (endHint) { + throw new MdInputContainerDuplicatedHintError('end'); + } + endHint = hint; + } + }); + } + } +} diff --git a/src/lib/input/input.ts b/src/lib/input/input.ts index 22023da9caa7..c6c5735d499a 100644 --- a/src/lib/input/input.ts +++ b/src/lib/input/input.ts @@ -3,7 +3,6 @@ import { Component, HostBinding, Input, - Directive, AfterContentInit, ContentChild, SimpleChange, @@ -17,13 +16,15 @@ import { Output, NgModule, ModuleWithProviders, - ViewEncapsulation, + ViewEncapsulation } from '@angular/core'; import {NG_VALUE_ACCESSOR, ControlValueAccessor, FormsModule} from '@angular/forms'; import {CommonModule} from '@angular/common'; import {MdError, coerceBooleanProperty} from '../core'; import {Observable} from 'rxjs/Observable'; +import {MdPlaceholder, MdInputContainer, MdHint, MdInputDirective} from './input-container'; import {MdTextareaAutosize} from './autosize'; +import {PlatformModule} from '../core/platform/index'; const noop = () => {}; @@ -65,30 +66,6 @@ export class MdInputDuplicatedHintError extends MdError { } - -/** - * The placeholder directive. The content can declare this to implement more - * complex placeholders. - */ -@Directive({ - selector: 'md-placeholder' -}) -export class MdPlaceholder {} - - -/** The hint directive, used to tag content as hint labels (going under the input). */ -@Directive({ - selector: 'md-hint', - host: { - '[class.md-right]': 'align == "end"', - '[class.md-hint]': 'true' - } -}) -export class MdHint { - // Whether to align the hint label at the start or end of the line. - @Input() align: 'start' | 'end' = 'start'; -} - /** * Component that represents a text input. It encapsulates the HTMLElement and * improve on its behaviour, along with styling it according to the Material Design. @@ -369,15 +346,33 @@ export class MdInput implements ControlValueAccessor, AfterContentInit, OnChange @NgModule({ - declarations: [MdPlaceholder, MdInput, MdHint, MdTextareaAutosize], - imports: [CommonModule, FormsModule], - exports: [MdPlaceholder, MdInput, MdHint, MdTextareaAutosize], + declarations: [ + MdInput, + MdPlaceholder, + MdInputContainer, + MdHint, + MdTextareaAutosize, + MdInputDirective + ], + imports: [ + CommonModule, + FormsModule, + PlatformModule, + ], + exports: [ + MdInput, + MdPlaceholder, + MdInputContainer, + MdHint, + MdTextareaAutosize, + MdInputDirective + ], }) export class MdInputModule { static forRoot(): ModuleWithProviders { return { ngModule: MdInputModule, - providers: [] + providers: PlatformModule.forRoot().providers, }; } } diff --git a/src/lib/module.ts b/src/lib/module.ts index 8819356f0aa3..c74a5bcf61a5 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -32,7 +32,7 @@ import {MdToolbarModule} from './toolbar/index'; import {MdTooltipModule} from './tooltip/index'; import {MdMenuModule} from './menu/index'; import {MdDialogModule} from './dialog/index'; -import {PlatformModule} from './core/platform/platform'; +import {PlatformModule} from './core/platform/index'; import {MdAutocompleteModule} from './autocomplete/index'; const MATERIAL_MODULES = [ diff --git a/src/lib/sidenav/sidenav.spec.ts b/src/lib/sidenav/sidenav.spec.ts index 5678797639b1..34062c9e9d39 100644 --- a/src/lib/sidenav/sidenav.spec.ts +++ b/src/lib/sidenav/sidenav.spec.ts @@ -3,7 +3,7 @@ import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdSidenav, MdSidenavModule, MdSidenavToggleResult} from './sidenav'; import {A11yModule} from '../core/a11y/index'; -import {PlatformModule} from '../core/platform/platform'; +import {PlatformModule} from '../core/platform/index'; import {ESCAPE} from '../core/keyboard/keycodes';