Recently, I’ve been working pretty furiously on the rewrite of Gloss, my first serious ExtJS application. I wrote Gloss in ExtJS 3, but wanted to give it a much-needed overhaul now that ExtJS 4 is out.

One of the more useful features of Gloss (well, I think it is at least) is the search functionality. In my rework, I’ve been trying to solidify some of the keyboard navigation to allow for a more fluid experience.

Ideally, I want the flow to go something like this:

  1. Enter a search, with as-you-type results
  2. When the full list of results is returned, be able to TAB from search text field to first row in the list of results
  3. Be able to TAB or DOWN ARROW to the next result in the list
  4. When focused on the desired result, be able to strike ENTER or SPACE to load the record

I’ll start with the last two: navigating inside the grid panel itself.

Grid Navigation

With ExtJS 4, this is stupid easy. In my Search controller, I have some listeners set up. They’re keyed in on the “itemkeydown” event of the grid.

Ext.define("Gloss.controller.Search", {
   extend: "Ext.app.Controller",
   init: function() {
      this.control({
         "searchgrid *": {
            itemkeydown: function(grid,rec,item,idx,e,opts) {
               if(e.keyCode==13 || e.keyCode==32) {
                  ... do some stuff
               }
               if(e.keyCode==9) {
                  e.preventDefault();
                  var total = rec.store.getCount();
                  if(idx+1 < total) {
                     grid.select(idx+1);
                  }
               }
            }
         }
      });
   }
});

So nothing too crazy here. If someone strikes the ENTER or SPACE keys while on a focused row (keyCode 13/32, respectively), I do whatever’s necessary to load the record. If, however, the key struck is a TAB (keyCode 9), I take a little bit different track.

First, I call preventDefault on the event object passed from the key action. While I’ll get into this a bit later, this will *prevent* the TAB action from moving focus to some other element on the page (like a text field or something like that).

Next, I get the total count of my store from the record reference passed in the event. If the current index of the row (plus 1) is less than the total, I invoke the select method of the grid class, telling it to move its internal reference to the next record.

FYI, the DOWN ARROW functionality works OOTB…no need to capture the keyboard event.

From Text to Grid Row

Ok, so much for items 3 and 4. Now let’s go back to 1 and (more importantly) 2: TAB-ing from the textfield to the first result in the grid panel.

To be honest, I spent a lot more time on this than I wanted to. Part of it was a bit of thick-headedness on my part.

Here was my first attempt:

Ext.define("Gloss.controller.Search", {
   extend: "Ext.app.Controller",
   init: function() {
      this.control({
         "searchform textfield": {
            keydown: function(fld,e,opts) {
               if(e.keyCode==9) {
                  var grid = this.getSearchGrid();
                  if(grid.store.getCount() > 0) {
                     grid.getView().focusRow(0);
                     grid.getSelectionModel().select(0);
                  }
               }
            },
         }
      });
   }
});

So quick overview: On the textfield in my “searchform” view, I apply an event listener for the “keydown” event. If it’s a TAB (9), I dig into the grid’s view and selection objects, calling the focusRow() and select() methods. In short, these mimic what happens when you click a row in the grid with your mouse…

The problem with this approach is with the focusRow() method. Sure enough, it works. If you press the TAB in the text field, it *appears* that the first row in the grid results gets focus. Heck, a special “focused” class is even applied to the row’s containing HTML element.

But it doesn’t really work, at least not in a full-on “focus” sense. Leaving out any stoppage or default-action-prevention of the TAB event, the browser focus will NOT be placed on the first row of the grid results. Rather, it will move onto something else.

Why is this? Well, it’s simple. There’s only so many HTML elements that actually take focus by default. Form inputs do. Link tags do. Table rows, by default, do not. So even though there is the appearance of focus, there’s no actually representation within the browser of the table row of the first grid row receiving focus.

The work-around? Well, it feels kind of kludgy, but it works. While most HTML elements don’t necessarily receive focus automatically, they *can* receive focus if they have a tabIndex specified. So my solution was pretty simple.

First, I set a generous tabIndex of 1000 on my search form field. Next, I applied a tabIndex of 1001 to the root element of the gridpanel, using the afterrender event:

"searchgrid": {
   afterrender: function(grid,opts) {
      grid.view.getEl().set({tabindex:1001})
   }
}...

With this in place, the normal flow of striking the TAB key will occur: e.g., focus will move from the currently focused element to that with the next highest tabIndex.

And because I already have a listener applied to the grid for the itemkeydown event, this event handler will pick up the navigation-management bits now that focus is within the grid panel.

Some Final Thoughts

As I wrapped this up, I thought about possibly tapping into the grid panel’s templating system in order to simply apply a consecutive tabIndex to each rendered row. This would avoid having to to capture and override keyboard events when navigating the grid. However, I finally decided to stick with what I’ve outlined above, simply because there are a number of other things that I want to do after I’ve trapped these events…it seems cleaner to manage these fully in the controller, so that’s what I’ll do.

That said, I hardly advocate that this is best way to accomplish this functionality. There may be something very obvious that I am missing that would alleviate the work around altogether. If you have ideas, or questions, please leave a comment!