Adding spatial navigation and tabindex to your custom forms

Having correctly implemented spatial navigation in your custom forms is more important, than ever these days. Unfortunately Joomla's core JForm does not support adding the required info easily (for example by creating an XML file which will output the correct code.

Essentially one would need to simply add to field definitions two extra parameters, one for tabindex and another for spatial navigation styling, like here:

spNavStyle="nav-left:#jform_city; nav-right:#jform_zip"tabindex="9"

and to have the output looking like this:

<input name="jform[state]" id="jform_state" value="" class="required" tabindex="9" style="nav-left:#jform_city; nav-right:#jform_zip" required="required" aria-required="true" type="text">

Fortunately, if you aren't afraid to making your hands dirty with some PHP, this can be done, relatively easily.

Let's take a look to 2 base scenarios:

1. Use one of core Joomla field types

Let's take the field type "text" - the most common filed for the start. We will work with the Joomla 3.*, but with a little ingenuity, you can adapt the solution for any version of Joomla.

As a first step we will create a local override of the core Joomla file. The file responsible for handling the XML entries for the field type "text" is located in "WEBROOT/libraries/joomla/form/fields" and is named text.php. Let's copy this file to "WEBROOT/com_mycomponent/models/fields" and edit it a bit:

<?php
/**
 * @package     Joomla.Platform
 * @subpackage  Form
 *
 * @copyright   Copyright (C) 2005 - 2015 Open Source Matters, Inc. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE
 */

defined('JPATH_PLATFORM') or die;

/**
 * Form Field class for the Joomla Platform.
 * Supports a one line text field.
 *
 * @link   http://www.w3.org/TR/html-markup/input.text.html#input.text
 * @since  11.1
 */
class JFormFieldText extends JFormField
{
    /**
     * The form field type.
     *
     * @var    string
     *
     * @since  11.1
     */
    protected $type = 'Text';
    protected $tabIndex;
    protected $spNavStyle;

    /**
     * The allowable maxlength of the field.
     *
     * @var    integer
     * @since  3.2
     */
    protected $maxLength;

    /**
     * The mode of input associated with the field.
     *
     * @var    mixed
     * @since  3.2
     */
    protected $inputmode;

    /**
     * The name of the form field direction (ltr or rtl).
     *
     * @var    string
     * @since  3.2
     */
    protected $dirname;

    /**
     * Method to get certain otherwise inaccessible properties from the form field object.
     *
     * @param   string  $name  The property name for which to the the value.
     *
     * @return  mixed  The property value or null.
     *
     * @since   3.2
     */
    public function __get($name)
    {
        switch ($name)
        {
            case 'maxLength':
            case 'dirname':
            case 'inputmode':
                return $this->$name;
        }

        return parent::__get($name);
    }

    /**
     * Method to set certain otherwise inaccessible properties of the form field object.
     *
     * @param   string  $name   The property name for which to the the value.
     * @param   mixed   $value  The value of the property.
     *
     * @return  void
     *
     * @since   3.2
     */
    public function __set($name, $value)
    {
        switch ($name)
        {
            case 'maxLength':
                $this->maxLength = (int) $value;
                break;

            case 'dirname':
                $value = (string) $value;
                $value = ($value == $name || $value == 'true' || $value == '1');

            case 'inputmode':
                $this->name = (string) $value;
                break;

            default:
                parent::__set($name, $value);
        }
    }

    /**
     * Method to attach a JForm object to the field.
     *
     * @param   SimpleXMLElement  $element  The SimpleXMLElement object representing the <field /> tag for the form field object.
     * @param   mixed             $value    The form field value to validate.
     * @param   string            $group    The field name group control value. This acts as as an array container for the field.
     *                                      For example if the field has name="foo" and the group value is set to "bar" then the
     *                                      full field name would end up being "bar[foo]".
     *
     * @return  boolean  True on success.
     *
     * @see     JFormField::setup()
     * @since   3.2
     */
    public function setup(SimpleXMLElement $element, $value, $group = null)
    {
        $result = parent::setup($element, $value, $group);

        if ($result == true)
        {
            $inputmode = (string) $this->element['inputmode'];
            $dirname = (string) $this->element['dirname'];

            $this->inputmode = '';
            $inputmode = preg_replace('/\s+/', ' ', trim($inputmode));
            $inputmode = explode(' ', $inputmode);

            if (!empty($inputmode))
            {
                $defaultInputmode = in_array('default', $inputmode) ? JText::_("JLIB_FORM_INPUTMODE") . ' ' : '';

                foreach (array_keys($inputmode, 'default') as $key)
                {
                    unset($inputmode[$key]);
                }

                $this->inputmode = $defaultInputmode . implode(" ", $inputmode);
            }

            // Set the dirname.
            $dirname = ((string) $dirname == 'dirname' || $dirname == 'true' || $dirname == '1');
            $this->dirname = $dirname ? $this->getName($this->fieldname . '_dir') : false;
            $this->tabIndex = (int)$this->element['tabindex'];
            $this->spNavStyle = (string)$this->element['spNavStyle'];
            $this->maxLength = (int) $this->element['maxlength'];
        }

        return $result;
    }

    /**
     * Method to get the field input markup.
     *
     * @return  string  The field input markup.
     *
     * @since   11.1
     */
    protected function getInput()
    {
        // Translate placeholder text
        $hint = $this->translateHint ? JText::_($this->hint) : $this->hint;

        // Initialize some field attributes.
        $size         = !empty($this->size) ? ' size="' . $this->size . '"' : '';
        $maxLength    = !empty($this->maxLength) ? ' maxlength="' . $this->maxLength . '"' : '';
        $class        = !empty($this->class) ? ' class="' . $this->class . '"' : '';
        $readonly     = $this->readonly ? ' readonly' : '';
        $disabled     = $this->disabled ? ' disabled' : '';
        $required     = $this->required ? ' required aria-required="true"' : '';
        $hint         = $hint ? ' placeholder="' . $hint . '"' : '';
        $autocomplete = !$this->autocomplete ? ' autocomplete="off"' : ' autocomplete="' . $this->autocomplete . '"';
        $autocomplete = $autocomplete == ' autocomplete="on"' ? '' : $autocomplete;
        $autofocus    = $this->autofocus ? ' autofocus' : '';
        $spellcheck   = $this->spellcheck ? '' : ' spellcheck="false"';
        $pattern      = !empty($this->pattern) ? ' pattern="' . $this->pattern . '"' : '';
        $inputmode    = !empty($this->inputmode) ? ' inputmode="' . $this->inputmode . '"' : '';
        $dirname      = !empty($this->dirname) ? ' dirname="' . $this->dirname . '"' : '';
        $tabindex     = !empty($this->tabIndex) ? ' tabindex="' . $this->tabIndex . '"' : '';
        $spNavStyle   = !empty($this->spNavStyle) ? ' style="' . $this->spNavStyle . '"' : '';

        // Initialize JavaScript field attributes.
        $onchange = !empty($this->onchange) ? ' onchange="' . $this->onchange . '"' : '';

        // Including fallback code for HTML5 non supported browsers.
        JHtml::_('jquery.framework');
        JHtml::_('script', 'system/html5fallback.js', false, true);

        $datalist = '';
        $list     = '';

        /* Get the field options for the datalist.
        Note: getSuggestions() is deprecated and will be changed to getOptions() with 4.0. */
        $options  = (array) $this->getSuggestions();

        if ($options)
        {
            $datalist = '<datalist id="' . $this->id . '_datalist">';

            foreach ($options as $option)
            {
                if (!$option->value)
                {
                    continue;
                }

                $datalist .= '<option value="' . $option->value . '">' . $option->text . '</option>';
            }

            $datalist .= '</datalist>';
            $list     = ' list="' . $this->id . '_datalist"';
        }

        $html[] = '<input type="text" name="' . $this->name . '" id="' . $this->id . '"' . $dirname . ' value="'
            . htmlspecialchars($this->value, ENT_COMPAT, 'UTF-8') . '"' . $class . $tabindex . $spNavStyle .$size . $disabled . $readonly . $list
            . $hint . $onchange . $maxLength . $required . $autocomplete . $autofocus . $spellcheck . $inputmode . $pattern . ' />';
        $html[] = $datalist;

        return implode($html);
    }

    /**
     * Method to get the field options.
     *
     * @return  array  The field option objects.
     *
     * @since   3.4
     */
    protected function getOptions()
    {
        $options = array();

        foreach ($this->element->children() as $option)
        {
            // Only add <option /> elements.
            if ($option->getName() != 'option')
            {
                continue;
            }

            // Create a new option object based on the <option /> element.
            $options[] = JHtml::_(
                'select.option', (string) $option['value'],
                JText::alt(trim((string) $option), preg_replace('/[^a-zA-Z0-9_\-]/', '_', $this->fieldname)), 'value', 'text'
            );
        }

        return $options;
    }

    /**
     * Method to get the field suggestions.
     *
     * @return  array  The field option objects.
     *
     * @since       3.2
     * @deprecated  4.0  Use getOptions instead
     */
    protected function getSuggestions()
    {
        return $this->getOptions();
    }
}

The lines/words highlighted with RED are the newly added ones. This, combined with the proper entries in the form definition XML file:

<field name="state" type="text"
            description="COM_VIBRANTVIDEOS_REGISTER_STATE_DESC"
            filter="string"
            label="COM_VIBRANTVIDEOS_REGISTER_STATE_LABEL"
            required="true"
            spNavStyle="nav-left:#jform_city; nav-right:#jform_zip"
            tabindex="9"

        />

will give us the desired output.

2. The case of a custom field

This can be trickier - or simpler - depending on your custom field code. Let's presume, that you have a custom list field, wich generates an option list from a database table - in the example below, the available subscription plans - once again, the EXTRA code needed to add the spatial navigation code and the tabindex is highlighted with RED:

<?php

/**
 * @version     1.0.0
 * @package     com_mycomponent
 * @copyright   Copyright (C) 2014. All rights reserved.
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 * @author      Székely Dénes <This email address is being protected from spambots. You need JavaScript enabled to view it.; - http://webgobe.com
 */
defined('JPATH_BASE') or die;

jimport('joomla.form.formfield');

/**
 * Supports a value from an external table
 */
class JFormFieldPlan extends JFormField {

    /**
     * The form field type.
     *
     * @var        string
     * @since    1.6
     */
    protected $type = 'plan';
    protected $tabIndex;
    protected $spNavStyle;    
    

    /**
     * Method to get the field input markup for a generic list.
     * Use the multiple attribute to enable multiselect.
     *
     * @return  string  The field input markup.
     *
     * @since   11.1
     */
    protected function getInput()
    {
        $html = array();
        $attr = '';

        // Initialize some field attributes.
        $attr .= $this->element['class'] ? ' class="' . (string) $this->element['class'] . '"' : '';

        // To avoid user's confusion, readonly="true" should imply disabled="true".
        if ((string) $this->element['readonly'] == 'true' || (string) $this->element['disabled'] == 'true')
        {
            $attr .= ' disabled="disabled"';
        }

        $attr .= $this->element['size'] ? ' size="' . (int) $this->element['size'] . '"' : '';
        $attr .= $this->multiple ? ' multiple="multiple"' : '';
        $attr .= $this->required ? ' required="required" aria-required="true"' : '';
        $attr .= $this->element['tabindex'] ? ' tabindex="' . (int)$this->element['tabindex'] . '"' : '';
        $attr .= $this->element['spNavStyle'] ? ' style="' . (string)$this->element['spNavStyle'] . '"' : '';

        // Initialize JavaScript field attributes.
        $attr .= $this->element['onchange'] ? ' onchange="' . (string) $this->element['onchange'] . '"' : '';
        $attr .= $this->element['data-bind'] ? ' data-bind="' . (string) $this->element['data-bind'] . '"' : '';

        // Get the field options.    
        $db = JFactory::getDBO();
        $query = $db->getQuery(true);
        
        $query           
            ->select($db->quoteName('a.id','id'))
            ->select($db->quoteName('a.name', 'name'))
            ->from($db->quoteName('#__mycomponent_plan', 'a'))
            ->order($db->quoteName('a.id') . ' ASC');
        $db->setQuery((string)$query);
        $items = $db->loadObjectList();
        $options = array();
        if($items){
            foreach($items as $item){
                $options[] = JHtml::_('select.option', $item->id, ucwords($item->name));
            };
        };
        $html[] = JHtml::_('select.genericlist', $options, $this->name, trim($attr), 'value', 'text', $this->value);
        return implode($html);
    }
}