Monday, October 21, 2013

Simulate Bootstrap Focus Effect on Non-Form Controls

While working on the MultiSearch contact demo, I wanted the widget to show the nice blue focus outline like other input boxes that have the Bootstrap form-control class. However, I did not want that effect on the input box used in the search. Instead, I wanted it on the DIV with the panel class which was defining the bounds of the widget. That means both focusing on the input and any interaction inside the panel's bounds should trigger the focus effect. It turns out that managing any clicks inside the widget were pretty easy to handle. However, since there is an input box inside the widget, it has a tab stop which allows a user to use the keyboard to move the focus on and off the input. I was trying really hard to keep this as simple as possible but focus/blur events are inherently difficult since they don't bubble up through the DOM. While jQuery solves this by mapping those events to focusin/out, listening to multiple event types (click and focusin) really caused a lot of headaches.

The solution I eventually settled on uses one click event handler on the MultiSearch widget container and then two handlers on the BODY element to catch click and focusin events. The former is used to turn off the focus when the click occurs outside of the widget and the latter is used to detect tabbing into an element that triggers a focus event. That focusin handler simply triggers a click event on the target so, if the target is inside a MultiSearch widget, it will cause the click handler to fire which enable the focus effect and, if applicable, remove it from another widget.

That's the description of the solution, here's the actual implementation. The MultiSearch widgets markup on the contacts demo looks like this:



There are three of these on the page and each will need the click handler to enable the focus effect. For simplicity, let's assume those three elements are already selected into a variable named $fields. The handler can be bound on that set:


      $fields.on( 'click', function simulateFocus( event ) {

            // $me is the widget container element.
            // $panel is what we want to add/remove the focus styling

            var $me = $( this ),
                $panel = $me.find( '.panel' );

            // Only if focus hasn't already been activated.  We don't need
            // multiple handlers listening to remove the focus class. 
            if ( !$panel.is( '.focus' ) ) {

               $panel.addClass( 'focus' );

               // Several things are happening here:
               //  1) This click event is still bubbling, listen to
               //     click now, and it will be caught before the popover
               //     ever appears.  Deferring it pushes the execution outside
               //     of the current call stack
               //  2) Clicks inside the popover are fine.  Use the $.has() function
               //     to see if any part of the target is or is inside the popover
               //     element.  Only remove if that is not true.
               _.defer( function() {
                  $( document.body ).on( 'click.focus', function( e ) {
                     if ( $me.has( e.target ).length === 0 ) {
                        $panel.removeClass( 'focus' );
                        $( document.body ).off( 'click.focus' );
                     }
                  });
               });
            }

         })


That takes care of everything except the focus event triggered when a user tabs into a field. The simplest solution is to just listen to every focusin event on the page and turn that into a click event on that element:


   $( document.body ).on( 'focusin', 'input, textarea', function( event ) {
      $( event.target ).trigger( 'click' );
   });


As a final note, here are the styles required to enable the effect on the panel class. These are copied from the form-control class:


.panel {
   margin: 0;
   transition: border-color 0.15s ease-in-out 0s, box-shadow 0.15s ease-in-out 0s;
}

.panel.focus {
   border-color: #66AFE9;
   box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 8px rgba(102, 175, 233, 0.6);
   outline: 0 none;
}



This example can be easily adapted as needed to turn a panel that contains several elements that, as a group, you want to consider a form control into something that looks like one complete entity when interacting with its content.