Fixing change events on radio inputs
I was tasked with rewriting a piece of horribly underperformant code recently and in the process of checking I hadn't introduced any bugs I came across some behavioural oddness in Webkit (I use the nightlies with the excellent inspector as a development browser) and IE. Part of the form involved showing or hiding different dropdowns based on which radio button was selected, and both these browsers had quirks in their change events.
In IE a change event is only fired when you blur away from the radio input. I'd come across this before so just filed it under "fix later" and moved on. When I was testing keyboard interaction however, Webkit threw me a curve ball: it doesn't fire a change event at all if you modify the selected radio using your keyboard.
Since I was relying on change events to reveal the correct portion of the form this completely wrecked accessibility. The hunt was on for an elegant, general-purpose solution that didn't involve sticking conditional checks into my existing event handlers.
Desired behaviour
For our uses on this project, Firefox was exhibiting the "correct" behaviour. That is, a change event is fired whenever the selection of the radio group changes. This includes clicking on an input (or label) directly, and tabbing into the control and using your keyboard to change the selection.
Webkit handles the clicking part fine, but tabbing in and using the keyboard to change the value fires no events at all. IE fires events for all changes, but only after blurring away from the newly selected radio which makes it appear one step behind where it should be.
Thought process
I initially though I could simply have the focus event fire the change event to make keyboard manipulation work. This works great but gives a false positive in Webkit when you tab into the radio group. Tabbing in doesn't change the state it just selects it, so I needed a way to check if something had changed as a result of the focus. I tried using the focus event to see if the event target was unchecked as the event fired. Unfortunately the focus event is fired after the checked state is updated, so we can't tell if anything changed.
To work out if anything has changed I put a was_checked property on
each input. When the focus event fires I can use this to see if a change
event needs to be fired. To guard against tabbing in when no options are
selected I can look at the checked property, as it will have been
updated before the event if it's been selected. After the change event
has been fired we can update the was_checked properties to match the
checked ones. We also need to update the was_checked property in
cases where focus is never given but a change event occurs, so this
update needs to happen in a change event.
This works great for Webkit (and doesn't harm Firefox), but IE has different ideas. Despite the change event being manually fired from the focus handler it fires the change event again when you blur away from the radio. In many cases that wouldn't matter, but we were resetting some state when the change event happened, so we didn't want it to fire more than once.
To stop IE firing a second change event I used the change handler to
check if was_checked is true. If so, a change event was already fired
so we need to stop any further events firing. jQuery provides a very
useful method stopImmediatePropagation which does just that. So long
as we make sure this is the first change handler to be called then
everything will go to plan. It's worth noting that you can't return
false from this function since the default action needs to occur to make
sure IE ends up with the focus in the correct place.
Wrapping it all up
Rather than leave this all as chained events everywhere we can tidy it up into a clean jQuery extension. By calling this at the start of your code the rest of your change events will Just Work. Note, for the duplicate change events in IE to be shorted out properly you need to attach this change handler before any others.
$.fn.fix_radios = function() {
function focus() {
// if this isn't checked then no option is yet selected. bail
if ( !this.checked ) return;
// if this wasn't already checked, manually fire a change event
if ( !this.was_checked ) {
$( this ).change();
}
}
function change( e ) {
// shortcut if already checked to stop IE firing again
if ( this.was_checked ) {
e.stopImmediatePropagation();
return;
}
// reset all the was_checked properties
$( "input[name=" + this.name + "]" ).each( function() {
this.was_checked = this.checked;
} );
}
// attach the handlers and return so chaining works
return this.focus( focus ).change( change );
}
// attach it to all radios on document ready
$( function() {
$( "input[type=radio]" ).fix_radios();
} );
I've bundled this all up in a demo document so you can compare behaviour the before and after behaviour side by side. Let me know if you find any bugs!