From 5fa6df0b2ae2a7e09b0a5c2adfe0926e31a3bdce Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Mon, 31 Oct 2016 17:32:50 -0700 Subject: [PATCH] feat(select): support disabling --- src/demo-app/select/select-demo.html | 37 ++++-- src/demo-app/select/select-demo.scss | 8 ++ src/demo-app/select/select-demo.ts | 15 ++- src/lib/core/style/_form-common.scss | 10 ++ src/lib/input/input.scss | 5 +- src/lib/select/_select-theme.scss | 18 ++- src/lib/select/option.html | 2 +- src/lib/select/option.ts | 26 ++++- src/lib/select/select.scss | 14 +++ src/lib/select/select.spec.ts | 166 ++++++++++++++++++++++++--- src/lib/select/select.ts | 36 +++++- 11 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 src/lib/core/style/_form-common.scss 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 9865c640173a..e66f3a9f913b 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -8,12 +8,25 @@ import {FormControl} from '@angular/forms'; styleUrls: ['select-demo.css'], }) export class SelectDemo { + isRequired = false; + isDisabled = false; + foods = [ {value: 'steak-0', viewValue: 'Steak'}, {value: 'pizza-1', viewValue: 'Pizza'}, {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'} + ]; + + foodControl = new FormControl(''); + + 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..c5e86788bf42 --- /dev/null +++ b/src/lib/core/style/_form-common.scss @@ -0,0 +1,10 @@ + +// Gradient for showing the dashed line when the input is disabled. +$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 228b371ca432..f97fdf863efb 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..6f3798fa12f7 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([aria-disabled='true']) & { color: md-color($primary); border-bottom: 1px solid md-color($primary); } - .ng-invalid.ng-touched & { + .ng-invalid.ng-touched:not([aria-disabled='true']) & { 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([aria-disabled='true']) & { color: md-color($primary); } - .ng-invalid.ng-touched & { + .ng-invalid.ng-touched:not([aria-disabled='true']) & { color: md-color($warn); } } @@ -40,10 +40,14 @@ .md-select-value { color: md-color($foreground, text); + + [aria-disabled='true'] & { + color: md-color($foreground, hint-text); + } } md-option { - &:hover, &:focus { + &:hover:not([aria-disabled='true']), &:focus:not([aria-disabled='true']) { background: md-color($background, hover); } @@ -52,5 +56,9 @@ color: md-color($primary); } + &[aria-disabled='true'] { + 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 f1f615ee1611..fb756504a561 100644 --- a/src/lib/select/option.ts +++ b/src/lib/select/option.ts @@ -8,15 +8,17 @@ 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()', '(click)': 'select(true)', '(keydown)': '_handleKeydown($event)' }, @@ -25,11 +27,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(); @@ -51,6 +65,9 @@ export class MdOption { /** Selects the option. */ select(isUserInput = false): void { + if (this.disabled && isUserInput) { + return; + } this._selected = true; this.onSelect.emit(isUserInput); } @@ -72,6 +89,11 @@ export class MdOption { } } + /** Returns the correct tabindex for the option depending on disabled state. */ + _getTabIndex() { + return this.disabled ? '-1' : '0'; + } + _getHostElement(): HTMLElement { return this._element.nativeElement; } diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index ad43ed196867..641aba7aefeb 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 { @@ -55,6 +64,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 13cc90145bb2..fa0862c78516 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', () => { @@ -295,6 +309,72 @@ 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'); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + + fixture.componentInstance.control.enable(); + fixture.detectChanges(); + expect(getComputedStyle(trigger).getPropertyValue('cursor')).toEqual('pointer'); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent).toContain('Steak'); + expect(fixture.componentInstance.select.panelOpen).toBe(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'); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + + fixture.componentInstance.isDisabled = false; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(getComputedStyle(trigger).getPropertyValue('cursor')).toEqual('pointer'); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent).toContain('Steak'); + expect(fixture.componentInstance.select.panelOpen).toBe(true); + }); + }); + })); + + }); + describe('animations', () => { let fixture: ComponentFixture; let trigger: HTMLElement; @@ -403,25 +483,48 @@ describe('MdSelect', () => { expect(select.getAttribute('aria-invalid')).toEqual('true'); }); + 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'); @@ -437,12 +540,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'); }); }); @@ -455,16 +569,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; @@ -473,6 +588,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 80fe9dcafbf6..f81fecf6b23d 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -20,6 +20,7 @@ import {Dir} from '../core/rtl/dir'; import {Subscription} from 'rxjs/Subscription'; import {SelectAnimations} from './select-animations'; import {ControlValueAccessor, NgControl} from '@angular/forms'; +import {coerceBooleanProperty} from '../core/coersion/boolean-property'; @Component({ moduleId: module.id, @@ -29,9 +30,10 @@ import {ControlValueAccessor, NgControl} from '@angular/forms'; encapsulation: ViewEncapsulation.None, host: { 'role': 'listbox', - 'tabindex': '0', + '[attr.tabindex]': '_getTabIndex()', '[attr.aria-label]': 'placeholder', - '[attr.aria-required]': '_required.toString()', + '[attr.aria-required]': 'required.toString()', + '[attr.aria-disabled]': 'disabled.toString()', '[attr.aria-invalid]': '_control?.invalid || "false"', '(keydown)': '_openOnActivate($event)', '(blur)': '_onBlur()' @@ -62,6 +64,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; @@ -87,13 +92,22 @@ 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; } set required(value: any) { - this._required = value != null && `${value}` !== 'false'; + this._required = coerceBooleanProperty(value); } @Output() onOpen = new EventEmitter(); @@ -127,6 +141,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr /** Opens the overlay panel. */ open(): void { + if (this.disabled) { + return; + } this._panelOpen = true; } @@ -167,6 +184,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; @@ -230,6 +255,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);