diff --git a/src/components/checkbox/checkbox.html b/src/components/checkbox/checkbox.html index 7303056d8589..c1fff754de90 100644 --- a/src/components/checkbox/checkbox.html +++ b/src/components/checkbox/checkbox.html @@ -1,5 +1,18 @@ -
+
- - + + diff --git a/src/components/checkbox/checkbox.scss b/src/components/checkbox/checkbox.scss index 1ad59c831cae..4564ab1f1764 100644 --- a/src/components/checkbox/checkbox.scss +++ b/src/components/checkbox/checkbox.scss @@ -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); diff --git a/src/components/checkbox/checkbox.spec.ts b/src/components/checkbox/checkbox.spec.ts index 05404886d01f..e95e977d6e48 100644 --- a/src/components/checkbox/checkbox.spec.ts +++ b/src/components/checkbox/checkbox.spec.ts @@ -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 = checkboxNativeElement.querySelector('input'); + labelElement = 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({stopPropagation: () => {}}); expect(checkboxInstance.checked).toBe(true); expect(checkboxInstance.indeterminate).toBe(false); - checkboxNativeElement.click(); + checkboxInstance.onInteractionEvent({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 = 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 = 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 = 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 = 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 = checkboxNativeElement.querySelector('input'); + labelElement = 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 = 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: `` +}) +class CheckboxWithAriaLabelledby {} +/** Simple test component with name attribute */ +@Component({ + directives: [MdCheckbox], + template: `` +}) +class CheckboxWithNameAttribute {} diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index 8ac0105ea657..13bf2395e233 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -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 = new EventEmitter(); @@ -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; diff --git a/src/components/radio/radio.scss b/src/components/radio/radio.scss index d1589500e1a2..24d66cc2cf78 100644 --- a/src/components/radio/radio.scss +++ b/src/components/radio/radio.scss @@ -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); diff --git a/src/core/style/_mixins.scss b/src/core/style/_mixins.scss index b9445b392c7a..b760828f1773 100644 --- a/src/core/style/_mixins.scss +++ b/src/core/style/_mixins.scss @@ -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; + } + } } \ No newline at end of file diff --git a/src/demo-app/checkbox/checkbox-demo.ts b/src/demo-app/checkbox/checkbox-demo.ts index 8f4a9b154405..7e4584697b9e 100644 --- a/src/demo-app/checkbox/checkbox-demo.ts +++ b/src/demo-app/checkbox/checkbox-demo.ts @@ -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({ diff --git a/src/demo-app/checkbox/nested-checklist.html b/src/demo-app/checkbox/nested-checklist.html index 309ffeddce79..4b721ab99759 100644 --- a/src/demo-app/checkbox/nested-checklist.html +++ b/src/demo-app/checkbox/nested-checklist.html @@ -2,7 +2,7 @@

Tasks

  • {{task.name}}