Como convertir un puñado de listas anidadas en un menú vertical multinivel con la ayuda de JavaScript de una manera no obstrusiba.

El problema

Para los que venimos del mundo del JavaScript de los navegadores 4.0 (antes conocido como DHTML) , construir un menu multinivel para nuestra página web con DHTML, es algo “normal” o cuanto menos facil de implementar con los cientos de librerias que lo implementan, pero en el mundo de la WEB 2.0 donde el uso del JavaScript ha de ser inobstrusivo (esto es, que la pagina funcione sin él) y la accesibilidad es una obligación, el uso tradicional del que venia hablando pues es cuanto menos incorrecto: el menú (y las páginas a las que apuntan sus items) simplemente no aparecen si el usuario lo tiene desactivado o su navegador no lo soporta.

Asi pues, se plantea la necesidad de encontrar otra manera de conseguir el mismo objetivo adaptándose a los entándares actuales a la vez que diseñamos nuestra web de una manera más lógica y separamos la estructura, la presentacion y el código para que lo más importante, el contenido de nuestra página pueda ser accedido sin dificultad.

Mi solución

Llegados a este punto, es de remarcar que la solución que planteo no es original en el sentido que otros han pensado lo mismo y han llegado a soluciones parecidas, como por ejemplo, menús multinivel sólo con CSS, pero yo buscaba algo más tradicional, del tipo “hasta que no hago click no se desplega el menu” y así acabe implementando este trozo de codigo.

Las características que definen la implementación son estas:

  • El menú estará definido en XHTML por unas serie de listas anidadas
  • Todas las opciones del menu han de poder ser visibles si JavaScript esta desactivado
  • El código ha de ser ligero y compatible con el DOM de los navegadores más importantes
  • Su uso ha de ser simple

Cómo usar el código

Antes de explicar su funcionamiento (podeis verlo en marcha en esta página), explicaré como incluirlo en vuestras páginas, simplemente se ha de incluir el fichero JavaScript:

<script src="DHmenuvm.js" type="text/javascript"></script>

Y después añadirlo al evento window.onLoad():

if (window.attachEvent) window.attachEvent('onload', DHmenuvm);
else window.addEventListener("load", DHmenuvm, true);

Opcionalmente, si se quiere aplicar el código a una lista con un identificador diferente a “DHmenuvm”:

function DHmenuvm_onLoad() {
  DHliMenu('ID del elemento');
}

if (window.attachEvent) window.attachEvent('onload', DHmenuvm_onLoad);
else window.addEventListener("load", DHmenuvm_onLoad, true);

El código para añadir el evento onload() es muy simple pero suficiente para un par de ejemplos, aunque siempre es conveniente algo más elaborado para un sitio en producción.

Para empezar

Lo primero que necesitamos es el markup XHTML con el que vamos a trabajar (por cierto, no recuerdo de dónde copie la lista). Para mantener los ejemplos de una manera mas compacta, el JavaScript, XHTML y CSS van juntos en un único fichero ¡Aunque recordad siempre que se han de mantener por separado! Como el ejemplo es un poco extenso, prefiero que consulteis el código fuente del ejemplo en vez de publicarlo para hacer la explicación mas sencilla de entender.

Como se puede ver, el código del que partimos son simplemente unas listas anidadas a las que hemos añadido un poco de estilo. El unico detalle remarcable es que hemos añadido un clase parent a las listas que tienen sublistas para dar una sensación visual de que hay más opciones. Tambien es remarcable que esto será lo que veran aquellos navegadores que no tengan habilitado JavaScript, lo que tampoco es tan malo si pensamos que ya habiamos destinado la parte izquierda para un menu.

Lo siguiente es ocultar todas las UL’s que están por debajo del primer nivel, lo cual a priori es facil, pero en la práctica se complica un poco, ya que hemos de buscar una regla concreta del CSS:

#DHmenuvm li ul {
  display:inline;
  margin:0;
}

y cambiar display:inline a display:none:

tagId = (tagId && (typeof(tagId) == 'string')) || 'DHmenuvm'

var dss = document.styleSheets[0]
var ss0 = dss.cssRules || dss.rules

for (var x in ss0)
  if ((ss0[x].selectorText + '').toLowerCase() == '#' + tagId.toLowerCase() + ' li ul')
    ss0[x].style.display = 'none'

Para ello identificamos el id de la lista principal (variable tagId). Este id es por defecto “DHmenuvm”, pero le podemos pasar a la función como primer argumento el que más nos interese, pero precisamente el primer argumento es el objeto event en caso de usar la función como argumento de window.onload:

if (window.attachEvent) window.attachEvent('onload', DHmenuvm);
else window.addEventListener("load", DHmenuvm, true);

Por lo que hemos de comprobar primero que es una cadena, de ahí la complicación. Después recorremos todos los selectores de la primera hoja de estilo (y si, seria mejor recorrer todas las hojas) buscando el selector que antes mencionamos para poner el display:none.

El meollo

Bueno, ya hemos ocultado las UL que están por debajo de la principal, ahora nos toca mostrarlas cuando hacemos click en los LI. Como se puede deducir, lo mas obvio es localizar todos los LI del elemento que vamos a convertir en un menú (en nuestro ejemplo “DHmenuvm”) y asignarles un evento onclick, pues dicho y hecho:

var li = document.getElementById(tagId)

if (li) {

  li = li.getElementsByTagName('LI');
  var prev_pc = 0;
  var prev_li = null;

  for (var i in li) {
    li[i].onclick = function(e) {

      // un montón de codigo por explicar todavía

    }
  }
}

El código se explica por si mismo (las variables de por medio nos servirán más adelante), asi que iremos por lo que de verdad importa, el código de la función onclick(), y lo primero que vamos a hacer es contar los UL’s que el LI actual tiene por encima:

var pc = 0;
var t = this;

while(t = t.parentNode) if (t.tagName == 'UL') pc++;

¿Y para que queremos saberlo? Pues para ocultar los items que estan visibles si clickeamos un item por debajo del ultimo mostrado; en nuestro menú de ejemplo, si desplegamos “Main Courses” y luego en “Burger Meals”, hemos de ocultar toda esa rama si clickeamos en “Drinks”. Y eso es lo que hace el código de abajo, claro que implementarlo es más dificil que explicarlo… Para entenderlo, primero hemos de definir la función dc():

function dc(l, s) {
  for (var c = l.firstChild; c = c.nextSibling;)
    if (c.tagName == 'UL') c.style.display = s? 'inline' : 'none';
}

Su funcionamiento es muy simple: le pasamos un elemento y cambia el display de los elementos UL en el mismo nivel que ese elemento en función del segundo argumento de la función. Con función, ahora es más facil ocultar los elementos. El resto de código sigue así:

if (pc <= prev_pc) {
  dc(prev_li, false);
  for (var c = prev_li, d = prev_pc - pc; d > 0 ;c = c.parentNode)
    if (c.tagName == 'UL') { c.style.display = 'none'; d-- }
}

Es decir, si el LI sobre el que hacemos click esta en el mismo nivel o por debajo del anterior que se pulsó, oculta todos los items en su mismo nivel y los que tiene por encima. Y echo esto, ahora ya solo nos queda mostrar los UL que hay en el mismo nivel y actualizar los valores de las variables de estado:

dc(this, true);
prev_pc = pc;
prev_li = this;

Por cierto, los lectores avispados ya se habran dado cuenta que al actualizar dichas variables estamos creando una closure pero tranquilos, estan funciones no tienen “pérdidas“.

Ya sólo nos queda un pequeño retoque para acabar nuestra función, que es cancelar el “bubling” del evento que estamos procesando:

if (e) e.cancelBubble = true; else event.cancelBubble = true;

Y ahora ya podemos mostrar completo todo el código

El código completo

function DHmenuvm(tagId) {
  function dc(l, s) {
    for (var c = l.firstChild; c = c.nextSibling;)
      if (c.tagName == 'UL') c.style.display = s? 'inline' : 'none';
  }

  tagId = (tagId && (typeof(tagId) == 'string')) || 'DHmenuvm'

  var dss = document.styleSheets[0]
  var ss0 = dss.cssRules || dss.rules

  for (var x in ss0)
    if ((ss0[x].selectorText + '').toLowerCase() == '#' + tagId.toLowerCase() + ' li ul')
      ss0[x].style.display = 'none'

  var li = document.getElementById(tagId)

  if (li) {

    li = li.getElementsByTagName('LI');
    var prev_pc = 0;
    var prev_li = null;

    for (var i in li) {

      li[i].onclick = function(e) {

        var pc = 0;
        var t = this;

        while(t = t.parentNode) if (t.tagName == 'UL') pc++;

        if (pc <= prev_pc) {
          dc(prev_li, false);
          for (var c = prev_li, d = prev_pc - pc; d > 0 ;c = c.parentNode)
            if (c.tagName == 'UL') { c.style.display = 'none'; d-- }
        }

        dc(this, true);
        prev_pc = pc;
        prev_li = this;

        if (e) e.cancelBubble = true; else event.cancelBubble = true;
      }
    }
  }
}

Descargas: menu-final.html | menu-comentado.html

Licencia

Este código es libre de usarse y modificarse en cualquier proyecto personal o comercial siempre que se mantenga un comentario en el mismo que contenga un link a esta página y mi autoría, concretamente algo como esto:

// Código original de Ismael Jurado
// http://www.ismaelj.com/articulos/menu-vertical-multinivel/