Skip to content

fix(radio-button): Radio buttons are not tab stops in Safari #436

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/components/radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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, '<md-radio-button></md-radio-button>')
.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, `
<md-radio-group name="my_group">
<md-radio-button></md-radio-button>
</md-radio-group>
`)
.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', () => {
Expand Down Expand Up @@ -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, `
<md-radio-group [(ngModel)]="choice">
<md-radio-button [value]="0"></md-radio-button>
<md-radio-button [value]="1"></md-radio-button>
</md-radio-group>`)
.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);
});
});
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
28 changes: 25 additions & 3 deletions src/components/radio/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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();
Expand Down