feat(select): support disabling (#1667)

This commit is contained in:
Kara
2016-11-11 15:02:52 -08:00
committed by Jeremy Elbourn
parent aa617f9641
commit d0f4c3ebc3
11 changed files with 311 additions and 38 deletions
+28 -9
View File
@@ -1,11 +1,30 @@
<div class="demo-select">
<md-select placeholder="Food" [formControl]="control" [required]="isRequired">
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }} </md-option>
</md-select>
<p> Value: {{ control.value }} </p>
<p> Touched: {{ control.touched }} </p>
<p> Dirty: {{ control.dirty }} </p>
<p> Status: {{ control.status }} </p>
<button md-button (click)="control.setValue('pizza-1')">SET VALUE</button>
<button md-button (click)="isRequired=!isRequired">TOGGLE REQUIRED</button>
<md-card>
<md-select placeholder="Food" [formControl]="foodControl">
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }} </md-option>
</md-select>
<p> Value: {{ foodControl.value }} </p>
<p> Touched: {{ foodControl.touched }} </p>
<p> Dirty: {{ foodControl.dirty }} </p>
<p> Status: {{ foodControl.status }} </p>
<button md-button (click)="foodControl.setValue('pizza-1')">SET VALUE</button>
<button md-button (click)="toggleDisabled()">TOGGLE DISABLED</button>
</md-card>
<md-card>
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="isRequired" [disabled]="isDisabled"
#drinkControl="ngModel">
<md-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.viewValue }}
</md-option>
</md-select>
<p> Value: {{ currentDrink }} </p>
<p> Touched: {{ drinkControl.touched }} </p>
<p> Dirty: {{ drinkControl.dirty }} </p>
<p> Status: {{ drinkControl.control?.status }} </p>
<button md-button (click)="currentDrink='sprite-1'">SET VALUE</button>
<button md-button (click)="isRequired=!isRequired">TOGGLE REQUIRED</button>
<button md-button (click)="isDisabled=!isDisabled">TOGGLE DISABLED</button>
</md-card>
</div>
+8
View File
@@ -1,2 +1,10 @@
.demo-select {
display: flex;
flex-flow: row wrap;
md-card {
width: 450px;
margin: 24px;
}
}
+12 -1
View File
@@ -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();
}
}
+12
View File
@@ -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;
}
+2 -3
View File
@@ -1,4 +1,5 @@
@import '../core/style/variables';
@import '../core/style/form-common';
$md-input-floating-placeholder-scale-factor: 0.75 !default;
@@ -151,11 +152,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 {
+13 -5
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
<ng-content></ng-content>
<div class="md-option-ripple" md-ripple md-ripple-background-color="rgba(0,0,0,0)"
<div class="md-option-ripple" *ngIf="!disabled" md-ripple md-ripple-background-color="rgba(0,0,0,0)"
[md-ripple-trigger]="_getHostElement()"></div>
+27 -4
View File
@@ -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 {
+14
View File
@@ -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 {
+163 -14
View File
@@ -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<HTMLElement>;
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<BasicSelect>;
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<HTMLElement>;
beforeEach(() => {
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
trigger.click();
fixture.detectChanges();
options =
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
});
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<HTMLElement>;
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<HTMLElement>;
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: `
<md-select placeholder="Food" [formControl]="control" [required]="isRequired">
<md-option *ngFor="let food of foods" [value]="food.value">{{ food.viewValue }}</md-option>
<md-option *ngFor="let food of foods" [value]="food.value" [disabled]="food.disabled">
{{ food.viewValue }}
</md-option>
</md-select>
`
})
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<MdOption>;
}
@Component({
selector: 'ng-model-select',
template: `
<md-select placeholder="Food" ngModel [disabled]="isDisabled">
<md-option *ngFor="let food of foods" [value]="food.value">{{ food.viewValue }}</md-option>
</md-select>
`
})
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<MdOption>;
}
/**
* TODO: Move this to core testing utility until Angular has event faking
* support.
+31 -1
View File
@@ -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);