diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index bb6476d0f200..974b5dc95a53 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -1,11 +1,30 @@
- - {{ food.viewValue }} - -

Value: {{ control.value }}

-

Touched: {{ control.touched }}

-

Dirty: {{ control.dirty }}

-

Status: {{ control.status }}

- - + + + {{ food.viewValue }} + +

Value: {{ foodControl.value }}

+

Touched: {{ foodControl.touched }}

+

Dirty: {{ foodControl.dirty }}

+

Status: {{ foodControl.status }}

+ + +
+ + + + + {{ drink.viewValue }} + + +

Value: {{ currentDrink }}

+

Touched: {{ drinkControl.touched }}

+

Dirty: {{ drinkControl.dirty }}

+

Status: {{ drinkControl.control?.status }}

+ + + +
+
diff --git a/src/demo-app/select/select-demo.scss b/src/demo-app/select/select-demo.scss index 9c6784bed050..3594a998b107 100644 --- a/src/demo-app/select/select-demo.scss +++ b/src/demo-app/select/select-demo.scss @@ -1,2 +1,10 @@ .demo-select { + display: flex; + flex-flow: row wrap; + + md-card { + width: 450px; + margin: 24px; + } + } \ No newline at end of file diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts index e3cd9f1978b6..f7bec82ca09d 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -9,6 +9,9 @@ import {FormControl} from '@angular/forms'; }) export class SelectDemo { isRequired = false; + isDisabled = false; + currentDrink: string; + foodControl = new FormControl(''); foods = [ {value: 'steak-0', viewValue: 'Steak'}, @@ -16,6 +19,14 @@ export class SelectDemo { {value: 'tacos-2', viewValue: 'Tacos'} ]; - control = new FormControl(''); + drinks = [ + {value: 'coke-0', viewValue: 'Coke'}, + {value: 'sprite-1', viewValue: 'Sprite', disabled: true}, + {value: 'water-2', viewValue: 'Water'} + ]; + + toggleDisabled() { + this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable(); + } } diff --git a/src/lib/core/style/_form-common.scss b/src/lib/core/style/_form-common.scss new file mode 100644 index 000000000000..d7660acaf01b --- /dev/null +++ b/src/lib/core/style/_form-common.scss @@ -0,0 +1,12 @@ + +// Gradient for showing the dashed line when the input is disabled. +// Unlike using a border, a gradient allows us to adjust the spacing of the dotted line +// to match the Material Design spec. +$md-underline-disabled-background-image: + linear-gradient(to right, rgba(0, 0, 0, 0.26) 0%, rgba(0, 0, 0, 0.26) 33%, transparent 0%); + +@mixin md-control-disabled-underline { + background-image: $md-underline-disabled-background-image; + background-size: 4px 1px; + background-repeat: repeat-x; +} \ No newline at end of file diff --git a/src/lib/input/input.scss b/src/lib/input/input.scss index 2ee932341e5d..3d0ef83a9f86 100644 --- a/src/lib/input/input.scss +++ b/src/lib/input/input.scss @@ -1,4 +1,5 @@ @import '../core/style/variables'; +@import '../core/style/form-common'; $md-input-floating-placeholder-scale-factor: 0.75 !default; @@ -149,11 +150,9 @@ md-input, md-textarea { border-top-style: solid; &.md-disabled { + @include md-control-disabled-underline(); border-top: 0; - background-image: $md-input-underline-disabled-background-image; background-position: 0; - background-size: 4px 1px; - background-repeat: repeat-x; } .md-input-ripple { diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss index 7e692683d7ff..3cb9960f4f9e 100644 --- a/src/lib/select/_select-theme.scss +++ b/src/lib/select/_select-theme.scss @@ -11,12 +11,12 @@ color: md-color($foreground, hint-text); border-bottom: 1px solid md-color($foreground, divider); - md-select:focus & { + md-select:focus:not(.md-select-disabled) & { color: md-color($primary); border-bottom: 1px solid md-color($primary); } - .ng-invalid.ng-touched & { + .ng-invalid.ng-touched:not(.md-select-disabled) & { color: md-color($warn); border-bottom: 1px solid md-color($warn); } @@ -25,11 +25,11 @@ .md-select-arrow { color: md-color($foreground, hint-text); - md-select:focus & { + md-select:focus:not(.md-select-disabled) & { color: md-color($primary); } - .ng-invalid.ng-touched & { + .ng-invalid.ng-touched:not(.md-select-disabled) & { color: md-color($warn); } } @@ -40,10 +40,14 @@ .md-select-value { color: md-color($foreground, text); + + .md-select-disabled & { + color: md-color($foreground, hint-text); + } } md-option { - &:hover, &:focus { + &:hover:not(.md-option-disabled), &:focus:not(.md-option-disabled) { background: md-color($background, hover); } @@ -52,5 +56,9 @@ color: md-color($primary); } + &.md-option-disabled { + color: md-color($foreground, hint-text); + } + } } diff --git a/src/lib/select/option.html b/src/lib/select/option.html index 4eb163792397..90659d08fbd9 100644 --- a/src/lib/select/option.html +++ b/src/lib/select/option.html @@ -1,3 +1,3 @@ -
\ No newline at end of file diff --git a/src/lib/select/option.ts b/src/lib/select/option.ts index fa2d41c9063a..6e1dec65da0b 100644 --- a/src/lib/select/option.ts +++ b/src/lib/select/option.ts @@ -8,15 +8,18 @@ import { ViewEncapsulation } from '@angular/core'; import {ENTER, SPACE} from '../core/keyboard/keycodes'; +import {coerceBooleanProperty} from '../core/coersion/boolean-property'; @Component({ moduleId: module.id, selector: 'md-option', host: { 'role': 'option', - 'tabindex': '0', + '[attr.tabindex]': '_getTabIndex()', '[class.md-selected]': 'selected', '[attr.aria-selected]': 'selected.toString()', + '[attr.aria-disabled]': 'disabled.toString()', + '[class.md-option-disabled]': 'disabled', '(click)': '_selectViaInteraction()', '(keydown)': '_handleKeydown($event)' }, @@ -25,11 +28,23 @@ import {ENTER, SPACE} from '../core/keyboard/keycodes'; encapsulation: ViewEncapsulation.None }) export class MdOption { - private _selected = false; + private _selected: boolean = false; + + /** Whether the option is disabled. */ + private _disabled: boolean = false; /** The form value of the option. */ @Input() value: any; + @Input() + get disabled() { + return this._disabled; + } + + set disabled(value: any) { + this._disabled = coerceBooleanProperty(value); + } + /** Event emitted when the option is selected. */ @Output() onSelect = new EventEmitter(); @@ -72,13 +87,21 @@ export class MdOption { } } + /** * Selects the option while indicating the selection came from the user. Used to * determine if the select's view -> model callback should be invoked. */ _selectViaInteraction() { - this._selected = true; - this.onSelect.emit(true); + if (!this.disabled) { + this._selected = true; + this.onSelect.emit(true); + } + } + + /** Returns the correct tabindex for the option depending on disabled state. */ + _getTabIndex() { + return this.disabled ? '-1' : '0'; } _getHostElement(): HTMLElement { diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index 850bed0c3fe1..38ac93d43cee 100644 --- a/src/lib/select/select.scss +++ b/src/lib/select/select.scss @@ -1,4 +1,5 @@ @import '../core/style/menu-common'; +@import '../core/style/form-common'; $md-select-trigger-height: 30px !default; $md-select-trigger-min-width: 112px !default; @@ -16,6 +17,14 @@ md-select { height: $md-select-trigger-height; min-width: $md-select-trigger-min-width; cursor: pointer; + + [aria-disabled='true'] & { + @include md-control-disabled-underline(); + border-bottom: transparent; + background-position: 0 bottom; + cursor: default; + user-select: none; + } } .md-select-placeholder { @@ -56,6 +65,11 @@ md-option { position: relative; cursor: pointer; outline: none; + + &[aria-disabled='true'] { + cursor: default; + user-select: none; + } } .md-option-ripple { diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index ee1d936039e8..add405463856 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -6,7 +6,7 @@ import {OverlayContainer} from '../core/overlay/overlay-container'; import {MdSelect} from './select'; import {MdOption} from './option'; import {Dir} from '../core/rtl/dir'; -import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; describe('MdSelect', () => { let overlayContainerElement: HTMLElement; @@ -14,8 +14,8 @@ describe('MdSelect', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdSelectModule.forRoot(), ReactiveFormsModule], - declarations: [BasicSelect], + imports: [MdSelectModule.forRoot(), ReactiveFormsModule, FormsModule], + declarations: [BasicSelect, NgModelSelect], providers: [ {provide: OverlayContainer, useFactory: () => { overlayContainerElement = document.createElement('div'); @@ -205,6 +205,20 @@ describe('MdSelect', () => { .toBe(fixture.componentInstance.options.last); }); + it('should not select disabled options', () => { + trigger.click(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options[2].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + expect(options[2].classList).not.toContain('md-selected'); + expect(fixture.componentInstance.select.selected).not.toBeDefined(); + }); + }); describe('forms integration', () => { @@ -315,6 +329,84 @@ describe('MdSelect', () => { }); + describe('disabled behavior', () => { + + it('should disable itself when control is disabled programmatically', () => { + const fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + let trigger = + fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('default', `Expected cursor to be default arrow on disabled control.`); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent) + .toEqual('', `Expected select panel to stay closed.`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(false, `Expected select panelOpen property to stay false.`); + + fixture.componentInstance.control.enable(); + fixture.detectChanges(); + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('pointer', `Expected cursor to be a pointer on enabled control.`); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent) + .toContain('Steak', `Expected select panel to open normally on re-enabled control`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(true, `Expected select panelOpen property to become true.`); + }); + + it('should disable itself when control is disabled using the property', async(() => { + const fixture = TestBed.createComponent(NgModelSelect); + fixture.detectChanges(); + + fixture.componentInstance.isDisabled = true; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + let trigger = + fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('default', `Expected cursor to be default arrow on disabled control.`); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent) + .toEqual('', `Expected select panel to stay closed.`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(false, `Expected select panelOpen property to stay false.`); + + fixture.componentInstance.isDisabled = false; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('pointer', `Expected cursor to be a pointer on enabled control.`); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent) + .toContain('Steak', `Expected select panel to open normally on re-enabled control`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(true, `Expected select panelOpen property to become true.`); + }); + }); + })); + + }); + describe('animations', () => { let fixture: ComponentFixture; let trigger: HTMLElement; @@ -427,25 +519,48 @@ describe('MdSelect', () => { .toEqual('true', `Expected aria-invalid attr to be true for invalid selects.`); }); + it('should set aria-disabled for disabled selects', () => { + expect(select.getAttribute('aria-disabled')).toEqual('false'); + + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + + expect(select.getAttribute('aria-disabled')).toEqual('true'); + }); + + it('should set the tabindex of the select to -1 if disabled', () => { + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + expect(select.getAttribute('tabindex')).toEqual('-1'); + + fixture.componentInstance.control.enable(); + fixture.detectChanges(); + expect(select.getAttribute('tabindex')).toEqual('0'); + }); + + }); describe('for options', () => { let trigger: HTMLElement; + let options: NodeListOf; beforeEach(() => { trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; trigger.click(); fixture.detectChanges(); + + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; }); it('should set the role of md-option to option', () => { - const option = overlayContainerElement.querySelector('md-option') as HTMLElement; - expect(option.getAttribute('role')).toEqual('option'); + expect(options[0].getAttribute('role')).toEqual('option'); + expect(options[1].getAttribute('role')).toEqual('option'); + expect(options[2].getAttribute('role')).toEqual('option'); }); it('should set aria-selected on each option', () => { - const options = - overlayContainerElement.querySelectorAll('md-option') as NodeListOf; expect(options[0].getAttribute('aria-selected')).toEqual('false'); expect(options[1].getAttribute('aria-selected')).toEqual('false'); expect(options[2].getAttribute('aria-selected')).toEqual('false'); @@ -461,12 +576,23 @@ describe('MdSelect', () => { expect(options[2].getAttribute('aria-selected')).toEqual('false'); }); - it('should set the tabindex of each option to 0', () => { - const options = - overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + it('should set the tabindex of each option according to disabled state', () => { expect(options[0].getAttribute('tabindex')).toEqual('0'); expect(options[1].getAttribute('tabindex')).toEqual('0'); - expect(options[2].getAttribute('tabindex')).toEqual('0'); + expect(options[2].getAttribute('tabindex')).toEqual('-1'); + }); + + it('should set aria-disabled for disabled options', () => { + expect(options[0].getAttribute('aria-disabled')).toEqual('false'); + expect(options[1].getAttribute('aria-disabled')).toEqual('false'); + expect(options[2].getAttribute('aria-disabled')).toEqual('true'); + + fixture.componentInstance.foods[2]['disabled'] = false; + fixture.detectChanges(); + + expect(options[0].getAttribute('aria-disabled')).toEqual('false'); + expect(options[1].getAttribute('aria-disabled')).toEqual('false'); + expect(options[2].getAttribute('aria-disabled')).toEqual('false'); }); }); @@ -479,15 +605,17 @@ describe('MdSelect', () => { selector: 'basic-select', template: ` - {{ food.viewValue }} + + {{ food.viewValue }} + ` }) class BasicSelect { - foods = [ + foods: any[] = [ { value: 'steak-0', viewValue: 'Steak' }, { value: 'pizza-1', viewValue: 'Pizza' }, - { value: 'tacos-2', viewValue: 'Tacos' }, + { value: 'tacos-2', viewValue: 'Tacos', disabled: true }, ]; control = new FormControl(); isRequired: boolean; @@ -496,6 +624,27 @@ class BasicSelect { @ViewChildren(MdOption) options: QueryList; } +@Component({ + selector: 'ng-model-select', + template: ` + + {{ food.viewValue }} + + ` +}) +class NgModelSelect { + foods: any[] = [ + { value: 'steak-0', viewValue: 'Steak' }, + { value: 'pizza-1', viewValue: 'Pizza' }, + { value: 'tacos-2', viewValue: 'Tacos' }, + ]; + isDisabled: boolean; + + @ViewChild(MdSelect) select: MdSelect; + @ViewChildren(MdOption) options: QueryList; +} + + /** * TODO: Move this to core testing utility until Angular has event faking * support. diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 09eefc69f60a..d64b70c43edb 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -30,9 +30,11 @@ import {coerceBooleanProperty} from '../core/coersion/boolean-property'; encapsulation: ViewEncapsulation.None, host: { 'role': 'listbox', - 'tabindex': '0', + '[attr.tabindex]': '_getTabIndex()', '[attr.aria-label]': 'placeholder', '[attr.aria-required]': 'required.toString()', + '[attr.aria-disabled]': 'disabled.toString()', + '[class.md-select-disabled]': 'disabled', '[attr.aria-invalid]': '_control?.invalid || "false"', '(keydown)': '_handleKeydown($event)', '(blur)': '_onBlur()' @@ -63,6 +65,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr /** Whether filling out the select is required in the form. */ private _required: boolean = false; + /** Whether the select is disabled. */ + private _disabled: boolean = false; + /** Manages keyboard events for options in the panel. */ _keyManager: ListKeyManager; @@ -88,6 +93,15 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr @Input() placeholder: string; + @Input() + get disabled() { + return this._disabled; + } + + set disabled(value: any) { + this._disabled = coerceBooleanProperty(value); + } + @Input() get required() { return this._required; @@ -128,6 +142,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr /** Opens the overlay panel. */ open(): void { + if (this.disabled) { + return; + } this._panelOpen = true; } @@ -169,6 +186,14 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr this._onTouched = fn; } + /** + * Disables the select. Part of the ControlValueAccessor interface required + * to integrate with Angular's core forms API. + */ + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + /** Whether or not the overlay panel is open. */ get panelOpen(): boolean { return this._panelOpen; @@ -234,6 +259,11 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr } } + /** Returns the correct tabindex for the select depending on disabled state. */ + _getTabIndex() { + return this.disabled ? '-1' : '0'; + } + /** Sets up a key manager to listen to keyboard events on the overlay panel. */ private _initKeyManager() { this._keyManager = new ListKeyManager(this.options);