Skip to content

Commit 6082d2f

Browse files
author
Marco Marche
committed
fix(radio-button): Radio buttons are not tab stops in Safari
fixes #417
1 parent 7f3b1bd commit 6082d2f

File tree

2 files changed

+90
-2
lines changed

2 files changed

+90
-2
lines changed

src/components/radio/radio.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ export function main() {
6161
.createAsync(TestApp)
6262
.then(fixture => {
6363
let button = fixture.debugElement.query(By.css('md-radio-button'));
64+
let input = button.query(By.css('input'));
6465

6566
fixture.detectChanges();
6667
expect(button.componentInstance.disabled).toBe(true);
68+
expect(input.nativeElement.hasAttribute('tabindex')).toBe(false);
6769
}).then(done);
6870
});
6971

@@ -149,6 +151,28 @@ export function main() {
149151
expect(button.nativeElement.classList.contains('md-radio-focused')).toBe(false);
150152
}).then(done);
151153
});
154+
it('should not scroll when pressing space on the checkbox', (done: () => void) => {
155+
builder
156+
.overrideTemplate(TestApp, '<md-radio-button></md-radio-button>')
157+
.createAsync(TestApp)
158+
.then(fixture => {
159+
let button = fixture.debugElement.query(By.css('md-radio-button'));
160+
161+
let keyboardEvent = dispatchKeyboardEvent('keydown', button.nativeElement, ' ');
162+
fixture.detectChanges();
163+
164+
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
165+
}).then(done);
166+
});
167+
it('should make the host element a tab stop', (done: () => void) => {
168+
builder
169+
.overrideTemplate(TestApp, '<md-radio-button></md-radio-button>')
170+
.createAsync(TestApp)
171+
.then(fixture => {
172+
let button = fixture.debugElement.query(By.css('md-radio-button'));
173+
expect(button.tabIndex).toBe(0);
174+
}).then(done);
175+
});
152176
});
153177

154178
describe('MdRadioDispatcher', () => {
@@ -368,3 +392,49 @@ class TestApp {
368392
class TestAppWithInitialValue {
369393
choice: number = 1;
370394
}
395+
396+
397+
// TODO(trik): remove eveything below when Angular supports faking events. - copy & paste from checkbox.spec.ts
398+
399+
400+
var BROWSER_SUPPORTS_EVENT_CONSTRUCTORS: boolean = (function() {
401+
// See: https://github.com/rauschma/event_constructors_check/blob/gh-pages/index.html#L39
402+
try {
403+
return new Event('submit', { bubbles: false }).bubbles === false &&
404+
new Event('submit', { bubbles: true }).bubbles === true;
405+
} catch (e) {
406+
return false;
407+
}
408+
})();
409+
410+
/**
411+
* Dispatches a keyboard event from an element.
412+
* @param eventName The name of the event to dispatch, such as "keydown".
413+
* @param element The element from which the event will be dispatched.
414+
* @param key The key tied to the KeyboardEvent.
415+
* @returns The artifically created keyboard event.
416+
*/
417+
function dispatchKeyboardEvent(eventName: string, element: HTMLElement, key: string): Event {
418+
let keyboardEvent: Event;
419+
if (BROWSER_SUPPORTS_EVENT_CONSTRUCTORS) {
420+
keyboardEvent = new KeyboardEvent(eventName);
421+
} else {
422+
keyboardEvent = document.createEvent('Event');
423+
keyboardEvent.initEvent(eventName, true, true);
424+
}
425+
426+
// Hack DOM Level 3 Events "key" prop into keyboard event.
427+
Object.defineProperty(keyboardEvent, 'key', {
428+
value: key,
429+
enumerable: false,
430+
writable: false,
431+
configurable: true,
432+
});
433+
434+
// Using spyOn seems to be the *only* way to determine if preventDefault is called, since it
435+
// seems that `defaultPrevented` does not get set with the technique.
436+
spyOn(keyboardEvent, 'preventDefault').and.callThrough();
437+
438+
element.dispatchEvent(keyboardEvent);
439+
return keyboardEvent;
440+
}

src/components/radio/radio.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,11 @@ export class MdRadioGroup implements AfterContentInit, ControlValueAccessor {
206206
styleUrls: ['./components/radio/radio.css'],
207207
encapsulation: ViewEncapsulation.None,
208208
host: {
209-
'(click)': 'onClick($event)'
209+
'[id]': 'id',
210+
'[attr.tabindex]': 'disabled ? null : tabindex',
211+
'(keydown.space)': 'onSpaceDown($event)',
212+
'(keyup.space)': 'onInteractionEvent($event)',
213+
'(click)': 'onInteractionEvent($event)'
210214
}
211215
})
212216
export class MdRadioButton implements OnInit {
@@ -225,6 +229,12 @@ export class MdRadioButton implements OnInit {
225229
@Input()
226230
name: string;
227231

232+
/**
233+
* The tabindex attribute for the radio button. Note that when the checkbox is disabled, the attribute
234+
* on the host element will be removed. It will be placed back when the radio button is re-enabled.
235+
*/
236+
@Input() tabindex: number = 0;
237+
228238
/** Whether this radio is disabled. */
229239
private _disabled: boolean;
230240

@@ -335,7 +345,15 @@ export class MdRadioButton implements OnInit {
335345
this._disabled = (value != null && value !== false) ? true : null;
336346
}
337347

338-
onClick(event: Event) {
348+
/**
349+
* Event handler used for (keydown.space) events. Used to prevent spacebar events from bubbling
350+
* when the component is focused, which prevents side effects like page scrolling from happening.
351+
*/
352+
onSpaceDown(evt: Event) {
353+
evt.preventDefault();
354+
}
355+
356+
onInteractionEvent(event: Event) {
339357
if (this.disabled) {
340358
event.preventDefault();
341359
event.stopPropagation();

0 commit comments

Comments
 (0)