diff --git a/src/components/radio/radio.spec.ts b/src/components/radio/radio.spec.ts index ca408650f200..1ec336ee1966 100644 --- a/src/components/radio/radio.spec.ts +++ b/src/components/radio/radio.spec.ts @@ -61,9 +61,11 @@ export function main() { .createAsync(TestApp) .then(fixture => { let button = fixture.debugElement.query(By.css('md-radio-button')); + let input = button.query(By.css('input')); fixture.detectChanges(); expect(button.componentInstance.disabled).toBe(true); + expect(input.nativeElement.hasAttribute('tabindex')).toBe(false); }).then(done); }); @@ -149,6 +151,33 @@ export function main() { expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(false); }).then(done); }); + it('should not scroll when pressing space on the checkbox', (done: () => void) => { + builder + .overrideTemplate(TestApp, '') + .createAsync(TestApp) + .then(fixture => { + let button = fixture.debugElement.query(By.css('md-radio-button')); + + let keyboardEvent = dispatchKeyboardEvent('keydown', button.nativeElement, ' '); + fixture.detectChanges(); + + expect(keyboardEvent.preventDefault).toHaveBeenCalled(); + }).then(done); + }); + it('should make the host element a tab stop', (done: () => void) => { + builder + .overrideTemplate(TestApp, ` + + + + `) + .createAsync(TestApp) + .then(fixture => { + let button = fixture.debugElement.query(By.css('md-radio-button')); + fixture.detectChanges(); + expect(button.nativeElement.tabIndex).toBe(0); + }).then(done); + }); }); describe('MdRadioDispatcher', () => { @@ -323,7 +352,28 @@ export function main() { }); }).then(done); }); + it('should deselect all buttons when model is null or undefined', (done: () => void) => { + builder + .overrideTemplate(TestAppWithInitialValue, ` + + + + `) + .createAsync(TestAppWithInitialValue) + .then(fixture => { + fakeAsync(function() { + let buttons = fixture.debugElement.queryAll(By.css('md-radio-button')); + fixture.detectChanges(); + fixture.componentInstance.choice = 0; + expect(isSinglySelected(buttons[0], buttons)).toBe(true); + + fixture.detectChanges(); + fixture.componentInstance.choice = null; + expect(allDeselected(buttons)).toBe(true); + }); + }).then(done); + }); }); } @@ -336,6 +386,13 @@ function isSinglySelected(button: DebugElement, buttons: DebugElement[]): boolea return component.checked && otherSelectedButtons.length == 0; } +/** Checks whether no button is selected from a group of buttons. */ +function allDeselected(buttons: DebugElement[]): boolean { + let selectedButtons = + buttons.filter((e: DebugElement) => e.componentInstance.checked); + return selectedButtons.length == 0; +} + /** Browser-agnostic function for creating an event. */ function createEvent(name: string): Event { let ev: Event; @@ -368,3 +425,50 @@ class TestApp { class TestAppWithInitialValue { choice: number = 1; } + + +// TODO(trik): remove eveything below when Angular supports faking events. +// copy & paste from checkbox.spec.ts + + +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; +} diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index 55f4a6207aaf..09740e6da98d 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -150,7 +150,10 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { }); if (matched.length == 0) { - // Didn't find a button that matches this value, return early without setting. + // Didn't find a button that matches this value, deselecting all buttons. + if (this._value == null) { + this.selected = null; + } return; } @@ -206,7 +209,11 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor { styleUrls: ['./components/radio/radio.css'], encapsulation: ViewEncapsulation.None, host: { - '(click)': 'onClick($event)' + '[id]': 'id', + '[attr.tabindex]': 'disabled ? null : tabindex', + '(keydown.space)': 'onSpaceDown($event)', + '(keyup.space)': 'onInteractionEvent($event)', + '(click)': 'onInteractionEvent($event)' } }) export class MdRadioButton implements OnInit { @@ -225,6 +232,13 @@ export class MdRadioButton implements OnInit { @Input() name: string; + /** + * The tabindex attribute for the radio button. Note that when the checkbox is disabled, + * the attribute on the host element will be removed. It will be placed back when the + * radio button is re-enabled. + */ + @Input() tabindex: number = 0; + /** Whether this radio is disabled. */ private _disabled: boolean; @@ -335,7 +349,15 @@ export class MdRadioButton implements OnInit { this._disabled = (value != null && value !== false) ? true : null; } - onClick(event: Event) { + /** + * 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(); + } + + onInteractionEvent(event: Event) { if (this.disabled) { event.preventDefault(); event.stopPropagation();