feat(checkbox): drive component with native checkbox (#415)

Also fixes lack of visual focus state.

closes #250
This commit is contained in:
Robert Messerle
2016-05-13 12:04:39 -07:00
committed by Jeremy Elbourn
parent 7323e89dde
commit 10ac2be980
8 changed files with 220 additions and 223 deletions
+17 -4
View File
@@ -1,5 +1,18 @@
<div class="md-checkbox-layout">
<label class="md-checkbox-layout">
<div class="md-checkbox-inner-container">
<input class="md-checkbox-input" type="checkbox"
[id]="inputId"
[checked]="checked"
[disabled]="disabled"
[name]="name"
[tabIndex]="tabindex"
[indeterminate]="indeterminate"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(change)="onInteractionEvent($event)">
<div class="md-ink-ripple"></div>
<div class="md-checkbox-frame"></div>
<div class="md-checkbox-background">
<svg version="1.1"
@@ -16,7 +29,7 @@
<div class="md-checkbox-mixedmark"></div>
</div>
</div>
<label [id]="labelId">
<span class="md-checkbox-label">
<ng-content></ng-content>
</label>
</div>
</span>
</label>
+12 -6
View File
@@ -1,6 +1,7 @@
@import "default-theme";
@import "theme-functions";
@import "variables";
@import "mixins";
/** The width/height of the checkbox element. */
$md-checkbox-size: $md-toggle-size !default;
@@ -217,11 +218,8 @@ $_md-checkbox-indeterminate-checked-easing-function: cubic-bezier(0.14, 0, 0, 1)
}
md-checkbox {
cursor: pointer;
&:focus {
// TODO(traviskaufman): Add ink ripple on focus state, once ripple is implemented.
outline: none;
&, label {
cursor: pointer;
}
}
@@ -251,7 +249,7 @@ md-checkbox {
}
// TODO(kara): Remove this style when fixing vertical baseline
.md-checkbox-layout label {
.md-checkbox-layout .md-checkbox-label {
line-height: 24px;
}
@@ -461,3 +459,11 @@ md-checkbox {
}
}
}
// Underlying native input element.
// Visually hidden but still able to respond to focus.
.md-checkbox-input {
@include md-visually-hidden;
}
@include md-temporary-ink-ripple(checkbox);
+98 -120
View File
@@ -13,7 +13,7 @@ import {By} from '@angular/platform-browser';
import {MdCheckbox} from './checkbox';
import {PromiseCompleter} from '../../core/async/promise-completer';
// TODO: Implement E2E tests for spacebar/click behavior for checking/unchecking
describe('MdCheckbox', () => {
let builder: TestComponentBuilder;
@@ -28,6 +28,8 @@ describe('MdCheckbox', () => {
let checkboxNativeElement: HTMLElement;
let checkboxInstance: MdCheckbox;
let testComponent: SingleCheckbox;
let inputElement: HTMLInputElement;
let labelElement: HTMLLabelElement;
beforeEach(async(() => {
builder.createAsync(SingleCheckbox).then(f => {
@@ -38,71 +40,79 @@ describe('MdCheckbox', () => {
checkboxNativeElement = checkboxDebugElement.nativeElement;
checkboxInstance = checkboxDebugElement.componentInstance;
testComponent = fixture.debugElement.componentInstance;
inputElement = <HTMLInputElement>checkboxNativeElement.querySelector('input');
labelElement = <HTMLLabelElement>checkboxNativeElement.querySelector('label');
});
}));
it('should add and remove the checked state', () => {
expect(checkboxInstance.checked).toBe(false);
expect(checkboxNativeElement.classList).not.toContain('md-checkbox-checked');
expect(checkboxNativeElement.getAttribute('aria-checked')).toBe('false');
expect(inputElement.checked).toBe(false);
testComponent.isChecked = true;
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(true);
expect(checkboxNativeElement.classList).toContain('md-checkbox-checked');
expect(checkboxNativeElement.getAttribute('aria-checked')).toBe('true');
expect(inputElement.checked).toBe(true);
testComponent.isChecked = false;
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(false);
expect(checkboxNativeElement.classList).not.toContain('md-checkbox-checked');
expect(checkboxNativeElement.getAttribute('aria-checked')).toBe('false');
expect(inputElement.checked).toBe(false);
});
it('should add and remove indeterminate state', () => {
expect(checkboxNativeElement.classList).not.toContain('md-checkbox-checked');
expect(checkboxNativeElement.getAttribute('aria-checked')).toBe('false');
expect(inputElement.checked).toBe(false);
expect(inputElement.indeterminate).toBe(false);
testComponent.isIndeterminate = true;
fixture.detectChanges();
expect(checkboxNativeElement.classList).toContain('md-checkbox-indeterminate');
expect(checkboxNativeElement.getAttribute('aria-checked')).toBe('mixed');
expect(inputElement.checked).toBe(false);
expect(inputElement.indeterminate).toBe(true);
testComponent.isIndeterminate = false;
fixture.detectChanges();
expect(checkboxNativeElement.classList).not.toContain('md-checkbox-indeterminate');
expect(checkboxNativeElement.getAttribute('aria-checked')).toBe('false');
expect(inputElement.checked).toBe(false);
expect(inputElement.indeterminate).toBe(false);
});
it('should toggle checked state on click', () => {
expect(checkboxInstance.checked).toBe(false);
checkboxNativeElement.click();
labelElement.click();
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(true);
checkboxNativeElement.click();
labelElement.click();
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(false);
});
it('should change from indeterminate to checked on click', () => {
testComponent.isChecked = false;
testComponent.isIndeterminate = true;
fixture.detectChanges();
checkboxNativeElement.click();
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(false);
expect(checkboxInstance.indeterminate).toBe(true);
checkboxInstance.onInteractionEvent(<Event>{stopPropagation: () => {}});
expect(checkboxInstance.checked).toBe(true);
expect(checkboxInstance.indeterminate).toBe(false);
checkboxNativeElement.click();
checkboxInstance.onInteractionEvent(<Event>{stopPropagation: () => {}});
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(false);
@@ -112,21 +122,23 @@ describe('MdCheckbox', () => {
it('should add and remove disabled state', () => {
expect(checkboxInstance.disabled).toBe(false);
expect(checkboxNativeElement.classList).not.toContain('md-checkbox-disabled');
expect(checkboxNativeElement.tabIndex).toBe(0);
expect(inputElement.tabIndex).toBe(0);
expect(inputElement.disabled).toBe(false);
testComponent.isDisabled = true;
fixture.detectChanges();
expect(checkboxInstance.disabled).toBe(true);
expect(checkboxNativeElement.classList).toContain('md-checkbox-disabled');
expect(checkboxNativeElement.hasAttribute('tabindex')).toBe(false);
expect(inputElement.disabled).toBe(true);
testComponent.isDisabled = false;
fixture.detectChanges();
expect(checkboxInstance.disabled).toBe(false);
expect(checkboxNativeElement.classList).not.toContain('md-checkbox-disabled');
expect(checkboxNativeElement.tabIndex).toBe(0);
expect(inputElement.tabIndex).toBe(0);
expect(inputElement.disabled).toBe(false);
});
it('should not toggle `checked` state upon interation while disabled', () => {
@@ -152,25 +164,13 @@ describe('MdCheckbox', () => {
expect(checkboxNativeElement.id).toBe('simple-check');
});
it('should create a label element with its own unique id for aria-labelledby', () => {
let labelElement = checkboxNativeElement.querySelector('label');
expect(labelElement.id).toBeTruthy();
expect(labelElement.id).not.toBe(checkboxNativeElement.id);
expect(checkboxNativeElement.getAttribute('aria-labelledby')).toBe(labelElement.id);
});
it('should project the checkbox content into the label element', () => {
let labelElement = checkboxNativeElement.querySelector('label');
expect(labelElement.textContent.trim()).toBe('Simple checkbox');
});
it('should mark the host element with role="checkbox"', () => {
expect(checkboxNativeElement.getAttribute('role')).toBe('checkbox');
let label = <HTMLLabelElement>checkboxNativeElement.querySelector('.md-checkbox-label');
expect(label.textContent.trim()).toBe('Simple checkbox');
});
it('should make the host element a tab stop', () => {
expect(checkboxNativeElement.tabIndex).toBe(0);
expect(inputElement.tabIndex).toBe(0);
});
it('should add a css class to end-align the checkbox', () => {
@@ -196,46 +196,6 @@ describe('MdCheckbox', () => {
return promiseCompleter.promise;
});
it('should stop propagation of interaction events when disabed', () => {
testComponent.isDisabled = true;
fixture.detectChanges();
checkboxNativeElement.click();
fixture.detectChanges();
expect(testComponent.parentElementClicked).toBe(false);
});
it('should not scroll when pressing space on the checkbox', () => {
let keyboardEvent = dispatchKeyboardEvent('keydown', checkboxNativeElement, ' ');
fixture.detectChanges();
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
});
it('should toggle the checked state when pressing space', () => {
dispatchKeyboardEvent('keyup', checkboxNativeElement, ' ');
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(true);
dispatchKeyboardEvent('keyup', checkboxNativeElement, ' ');
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(false);
});
it('should not toggle the checked state when pressing space if disabled', () => {
testComponent.isDisabled = true;
fixture.detectChanges();
dispatchKeyboardEvent('keyup', checkboxNativeElement, ' ');
fixture.detectChanges();
expect(checkboxInstance.checked).toBe(false);
expect(testComponent.parentElementKeyedUp).toBe(false);
});
describe('state transition css classes', () => {
it('should transition unchecked -> checked -> unchecked', () => {
testComponent.isChecked = true;
@@ -294,14 +254,47 @@ describe('MdCheckbox', () => {
describe('with provided aria-label ', () => {
let checkboxDebugElement: DebugElement;
let checkboxNativeElement: HTMLElement;
let inputElement: HTMLInputElement;
it('should use the provided aria-label', async(() => {
builder.createAsync(CheckboxWithAriaLabel).then(f => {
fixture = f;
checkboxDebugElement = fixture.debugElement.query(By.directive(MdCheckbox));
checkboxNativeElement = checkboxDebugElement.nativeElement;
inputElement = <HTMLInputElement>checkboxNativeElement.querySelector('input');
expect(checkboxNativeElement.getAttribute('aria-label')).toBe('Super effective');
fixture.detectChanges();
expect(inputElement.getAttribute('aria-label')).toBe('Super effective');
});
}));
});
describe('with provided aria-labelledby ', () => {
let checkboxDebugElement: DebugElement;
let checkboxNativeElement: HTMLElement;
let inputElement: HTMLInputElement;
it('should use the provided aria-labelledby', async(() => {
builder.createAsync(CheckboxWithAriaLabelledby).then(f => {
fixture = f;
checkboxDebugElement = fixture.debugElement.query(By.directive(MdCheckbox));
checkboxNativeElement = checkboxDebugElement.nativeElement;
inputElement = <HTMLInputElement>checkboxNativeElement.querySelector('input');
fixture.detectChanges();
expect(inputElement.getAttribute('aria-labelledby')).toBe('some-id');
});
}));
it('should not assign aria-labelledby if none is provided', async(() => {
builder.createAsync(SingleCheckbox).then(f => {
fixture = f;
checkboxDebugElement = fixture.debugElement.query(By.directive(MdCheckbox));
checkboxNativeElement = checkboxDebugElement.nativeElement;
inputElement = <HTMLInputElement>checkboxNativeElement.querySelector('input');
fixture.detectChanges();
expect(inputElement.getAttribute('aria-labelledby')).toBe(null);
});
}));
});
@@ -310,6 +303,8 @@ describe('MdCheckbox', () => {
let checkboxDebugElement: DebugElement;
let checkboxNativeElement: HTMLElement;
let testComponent: CheckboxWithTabIndex;
let inputElement: HTMLInputElement;
let labelElement: HTMLLabelElement;
beforeEach(async(() => {
builder.createAsync(CheckboxWithTabIndex).then(f => {
@@ -319,11 +314,13 @@ describe('MdCheckbox', () => {
testComponent = fixture.debugElement.componentInstance;
checkboxDebugElement = fixture.debugElement.query(By.directive(MdCheckbox));
checkboxNativeElement = checkboxDebugElement.nativeElement;
inputElement = <HTMLInputElement>checkboxNativeElement.querySelector('input');
labelElement = <HTMLLabelElement>checkboxNativeElement.querySelector('label');
});
}));
it('should preserve any given tabIndex', async(() => {
expect(checkboxNativeElement.tabIndex).toBe(7);
expect(inputElement.tabIndex).toBe(7);
}));
it('should preserve given tabIndex when the checkbox is disabled then enabled', () => {
@@ -336,7 +333,7 @@ describe('MdCheckbox', () => {
testComponent.isDisabled = false;
fixture.detectChanges();
expect(checkboxNativeElement.tabIndex).toBe(13);
expect(inputElement.tabIndex).toBe(13);
});
});
@@ -351,7 +348,7 @@ describe('MdCheckbox', () => {
it('should assign a unique id to each checkbox', () => {
let [firstId, secondId] =
fixture.debugElement.queryAll(By.directive(MdCheckbox))
.map(debugElement => debugElement.nativeElement.id);
.map(debugElement => debugElement.nativeElement.querySelector('input').id);
expect(firstId).toBeTruthy();
expect(secondId).toBeTruthy();
@@ -382,8 +379,22 @@ describe('MdCheckbox', () => {
}));
});
});
describe('with name attribute', () => {
beforeEach(async(() => {
builder.createAsync(CheckboxWithNameAttribute).then(f => {
f.detectChanges();
fixture = f;
});
}));
it('should forward name value to input element', fakeAsync(() => {
let checkboxElement = fixture.debugElement.query(By.directive(MdCheckbox));
let inputElement = <HTMLInputElement> checkboxElement.nativeElement.querySelector('input');
expect(inputElement.getAttribute('name')).toBe('test-name');
}));
});
});
/** Simple component for testing a single checkbox. */
@Component({
@@ -454,49 +465,16 @@ class CheckboxWithTabIndex {
})
class CheckboxWithAriaLabel { }
// TODO(jelbourn): remove eveything below when Angular supports faking events.
var BROWSER_SUPPORTS_EVENT_CONSTRUCTORS: boolean = (function() {
// See: https://github.com/rauschma/event_constructors_check/blob/gh-pages/index.html#L39
try {
return new Event('submit', { bubbles: false }).bubbles === false &&
new Event('submit', { bubbles: true }).bubbles === true;
} catch (e) {
return false;
}
})();
/**
* Dispatches a keyboard event from an element.
* @param eventName The name of the event to dispatch, such as "keydown".
* @param element The element from which the event will be dispatched.
* @param key The key tied to the KeyboardEvent.
* @returns The artifically created keyboard event.
*/
function dispatchKeyboardEvent(eventName: string, element: HTMLElement, key: string): Event {
let keyboardEvent: Event;
if (BROWSER_SUPPORTS_EVENT_CONSTRUCTORS) {
keyboardEvent = new KeyboardEvent(eventName);
} else {
keyboardEvent = document.createEvent('Event');
keyboardEvent.initEvent(eventName, true, true);
}
// Hack DOM Level 3 Events "key" prop into keyboard event.
Object.defineProperty(keyboardEvent, 'key', {
value: key,
enumerable: false,
writable: false,
configurable: true,
});
// Using spyOn seems to be the *only* way to determine if preventDefault is called, since it
// seems that `defaultPrevented` does not get set with the technique.
spyOn(keyboardEvent, 'preventDefault').and.callThrough();
element.dispatchEvent(keyboardEvent);
return keyboardEvent;
}
/** Simple test component with an aria-label set. */
@Component({
directives: [MdCheckbox],
template: `<md-checkbox aria-labelledby="some-id"></md-checkbox>`
})
class CheckboxWithAriaLabelledby {}
/** Simple test component with name attribute */
@Component({
directives: [MdCheckbox],
template: `<md-checkbox name="test-name"></md-checkbox>`
})
class CheckboxWithNameAttribute {}
+53 -50
View File
@@ -15,8 +15,6 @@ import {
ControlValueAccessor,
} from '@angular/common';
/**
* Monotonically increasing integer used to auto-generate unique ids for checkbox components.
*/
@@ -59,21 +57,11 @@ enum TransitionCheckState {
templateUrl: './components/checkbox/checkbox.html',
styleUrls: ['./components/checkbox/checkbox.css'],
host: {
'role': 'checkbox',
'[id]': 'id',
'[class.md-checkbox-indeterminate]': 'indeterminate',
'[class.md-checkbox-checked]': 'checked',
'[class.md-checkbox-disabled]': 'disabled',
'[class.md-checkbox-align-end]': 'align == "end"',
'[attr.tabindex]': 'disabled ? null : tabindex',
'[attr.aria-label]': 'ariaLabel',
'[attr.aria-labelledby]': 'labelId',
'[attr.aria-checked]': 'getAriaChecked()',
'[attr.aria-disabled]': 'disabled',
'(click)': 'onInteractionEvent($event)',
'(keydown.space)': 'onSpaceDown($event)',
'(keyup.space)': 'onInteractionEvent($event)',
'(blur)': 'onTouched()'
'[class.md-checkbox-focused]': 'hasFocus',
},
providers: [MD_CHECKBOX_CONTROL_VALUE_ACCESSOR],
encapsulation: ViewEncapsulation.None,
@@ -86,9 +74,19 @@ export class MdCheckbox implements ControlValueAccessor {
*/
@Input('aria-label') ariaLabel: string = '';
/**
* Users can specify the `aria-labelledby` attribute which will be forwarded to the input element
*/
@Input('aria-labelledby') ariaLabelledby: string = null;
/** A unique id for the checkbox. If one is not supplied, it is auto-generated. */
@Input() id: string = `md-checkbox-${++nextId}`;
/** ID to be applied to the `input` element */
get inputId(): string {
return `input-${this.id}`;
}
/** Whether or not the checkbox should come before or after the label. */
@Input() align: 'start' | 'end' = 'start';
@@ -104,6 +102,9 @@ export class MdCheckbox implements ControlValueAccessor {
*/
@Input() tabindex: number = 0;
/** Name value will be applied to the input element if present */
@Input() name: string = null;
/** Event emitted when the checkbox's `checked` value changes. */
@Output() change: EventEmitter<boolean> = new EventEmitter<boolean>();
@@ -123,6 +124,8 @@ export class MdCheckbox implements ControlValueAccessor {
private _changeSubscription: {unsubscribe: () => any} = null;
hasFocus: boolean = false;
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
/**
@@ -172,43 +175,6 @@ export class MdCheckbox implements ControlValueAccessor {
}
}
/** The id that is attached to the checkbox's label. */
get labelId() {
return `${this.id}-label`;
}
/** Returns the proper aria-checked attribute value based on the checkbox's state. */
getAriaChecked() {
if (this.indeterminate) {
return 'mixed';
}
return this.checked ? 'true' : 'false';
}
/** Toggles the checked state of the checkbox. If the checkbox is disabled, this does nothing. */
toggle() {
this.checked = !this.checked;
}
/**
* Event handler used for both (click) and (keyup.space) events. Delegates to toggle().
*/
onInteractionEvent(event: Event) {
if (this.disabled) {
event.stopPropagation();
return;
}
this.toggle();
}
/**
* Event handler used for (keydown.space) events. Used to prevent spacebar events from bubbling
* when the component is focused, which prevents side effects like page scrolling from happening.
*/
onSpaceDown(evt: Event) {
evt.preventDefault();
}
/** Implemented as part of ControlValueAccessor. */
writeValue(value: any) {
this.checked = !!value;
@@ -248,6 +214,43 @@ export class MdCheckbox implements ControlValueAccessor {
}
}
/**
* Informs the component when the input has focus so that we can style accordingly
* @internal
*/
onInputFocus() {
this.hasFocus = true;
}
/**
* Informs the component when we lose focus in order to style accordingly
* @internal
*/
onInputBlur() {
this.hasFocus = false;
this.onTouched();
}
/**
* Toggles the `checked` value between true and false
*/
toggle() {
this.checked = !this.checked;
}
/**
* Event handler for checkbox input element. Toggles checked state if element is not disabled.
* @param event
* @internal
*/
onInteractionEvent(event: Event) {
if (this.disabled) {
event.stopPropagation();
} else {
this.toggle();
}
}
private _getAnimationClassForCheckStateTransition(
oldState: TransitionCheckState, newState: TransitionCheckState): string {
var animSuffix: string;
+4 -36
View File
@@ -1,5 +1,6 @@
@import "default-theme";
@import "variables";
@import "mixins";
$md-radio-size: $md-toggle-size !default;
@@ -26,34 +27,6 @@ md-radio-button {
width: $md-radio-size;
}
// TODO(mtlin): Replace when ink ripple component is implemented.
// A placeholder ink ripple, shown when keyboard focused.
.md-ink-ripple {
background-color: md-color($md-accent);
border-radius: 50%;
height: 48px;
left: 10px;
opacity: 0;
pointer-events: none;
position: absolute;
top: 10px;
transform: translate(-50%,-50%);
transition: opacity ease 0.28s, background-color ease 0.28s;
width: 48px;
overflow: hidden;
// Fade in when radio focused.
.md-radio-focused & {
opacity: 0.1;
}
// TODO(mtlin): This corresponds to disabled focus state, but it's unclear how to enter into
// this state.
.md-radio-disabled & {
background: #000;
}
}
// The outer circle for the radio, always present.
.md-radio-outer-circle {
border-color: md-color($md-foreground, secondary-text);
@@ -117,17 +90,12 @@ md-radio-button {
// Underlying native input element.
// Visually hidden but still able to respond to focus.
.md-radio-input {
position: absolute;
width: 0;
height: 0;
margin: 0;
padding: 0;
opacity: 0;
appearance: none;
border: none;
@include md-visually-hidden;
}
// Basic disabled state.
.md-radio-disabled, .md-radio-disabled .md-radio-label {
cursor: default;
}
@include md-temporary-ink-ripple(radio);
+30
View File
@@ -24,4 +24,34 @@
position: absolute;
text-transform: none;
width: 1px;
}
@mixin md-temporary-ink-ripple($component) {
// TODO(mtlin): Replace when ink ripple component is implemented.
// A placeholder ink ripple, shown when keyboard focused.
.md-ink-ripple {
background-color: md-color($md-accent);
border-radius: 50%;
height: 48px;
left: 50%;
opacity: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
transition: opacity ease 0.28s, background-color ease 0.28s;
width: 48px;
// Fade in when radio focused.
.md-#{$component}-focused & {
opacity: 0.1;
}
// TODO(mtlin): This corresponds to disabled focus state, but it's unclear how to enter into
// this state.
.md-#{$component}-disabled & {
background: #000;
}
}
}
+5 -6
View File
@@ -40,8 +40,11 @@ class MdCheckboxDemoNestedChecklist {
}
];
allComplete(tasks: Task[]): boolean {
return tasks.every(t => t.completed);
allComplete(task: Task): boolean {
let subtasks = task.subtasks;
return subtasks.every(t => t.completed) ? true
: subtasks.every(t => !t.completed) ? false
: task.completed;
}
someComplete(tasks: Task[]): boolean {
@@ -52,10 +55,6 @@ class MdCheckboxDemoNestedChecklist {
setAllCompleted(tasks: Task[], completed: boolean) {
tasks.forEach(t => t.completed = completed);
}
updateOnSubtaskChange(task: Task) {
task.completed = this.allComplete(task.subtasks);
}
}
@Component({
+1 -1
View File
@@ -2,7 +2,7 @@
<ul>
<li *ngFor="let task of tasks">
<md-checkbox [(ngModel)]="task.completed"
[checked]="allComplete(task.subtasks)"
[checked]="allComplete(task)"
[indeterminate]="someComplete(task.subtasks)"
(change)="setAllCompleted(task.subtasks, $event)">
<h3>{{task.name}}</h3>