1. Transición del Sitio: de Bootstrap a Bulma, y sin jQuery

19 de Octubre de 2022

Este post es parte de la serie De Bootstrap a Bulma sin jQuery.

Una meta personal del 2021 fue reescribir los estilos del sitio, ya que el tema original (Hugo Resume) fue hecho en base a Bootstrap 4. Han sido dos las misiones: la transición a Bulma, y la desconexión de jQuery. Además, quería darle un toque más personal al sitio, ya que me percaté de que varias personas en mi entorno (colegas desarrolladores) también utilizaban el mismo tema 😬

El navbar vertical

Si bien Bulma cuenta con un componente llamado menu, este tenía el inconveniente de que no “colapsaba” en modo responsive, tal como lo hace el navbar. ¿La solución (o mejor dicho workaround)? Aprovechar la clase menu en tamaños de pantalla >= 992px, y en menores tamaños, la clase navbar.

Al navbar se le estableció un width de 17rem y un height de 100vh.
Cada ítem del menú es un <li>, contenido en un <ul> con las clases is-flex-desktop e is-flex-direction-column.


Aquí puede notarse la enorme diferencia al eliminar las propiedades y clases mencionadas previamente:


navbar horizontal con logo


Eliminando la imagen (navbar-brand), es más notoria la diferencia:


navbar horizontal sin logo


El ul está contenido dentro un div de clases navbar-menu, is-justify-content-center, y mb-auto. Removiendo la clase is-justify-content-center, luce como un menú “tradicional”:


navbar horizontal sin logo, con alineación izquierda

» ¿Cómo abrir y cerrar el menú?

Tal como se mencionó anteriormente, la idea era desconectarse de jQuery. Realmente hay muchas guías para lograr esto: una de ellas es que el botón de apertura/cierre del menú sea un checkbox disfrazado de botón (?) (una variante de la estrategia conocida como checkbox hack, para “detectar el clic”) y hacer la animación meramente con CSS puro. Esta opción me pareció interesante y casi me decanté por ella, hasta que leí una pregunta de “Calebmer” y una respuesta de “Graham Ritchie” (entre otras fuentes) en donde preguntan o mencionan que puede provocar problemas de accesibilidad. Lo tuve en cuenta especialmente porque suelo manejarme más con el teclado que con el mouse 😄 ¿Qué hice entonces? Acudí al viejo amigo JS.

En la propia documentación del navbar de Bulma se puede encontrar una implementación en JS para el toggle del menú. Lo que hice fue adaptar tal código.

document.addEventListener('DOMContentLoaded', () => {
    const claseActiva = 'is-active';

    // Obtener todos los elementos "navbar-burger"
    const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);

    // Verificar si hay algún navbar burger
    if ($navbarBurgers.length > 0) {

        // Agregar evento de click a cada uno de ellos
        $navbarBurgers.forEach( burger => {

            const target = burger.dataset.target; // el "botón hamburguesa" cuenta con el atributo data-target
            const $target = document.getElementById(target);

            // Agregar evento click al "botón hamburguesa" para el toggle del navbar-menu
            burger.addEventListener('click', () => {

                burger.classList.toggle(claseActiva);

                $target.classList.toggle(claseActiva);

                burger.setAttribute(
                'aria-expanded',
                burger.getAttribute('aria-expanded') === 'false' ? 'true': 'false'
                )
            });
        });
    }
});

» Cerrar el menú al seleccionar un ítem de este

Lo conseguí agregando un manejador al evento click (un event listener de click) en cada ítem del menú, donde se togglea la clase is-active tanto en el “botón hamburguesa”, como en el navbar-menu, y el nav como tal.

navbarMenu = $target.children[1]; // ¡en la posición 0 se encuentra el logo! en la 1 está el div del navbar-menu
liItems = navbarMenu.getElementsByTagName('li')
const listArray = [...liItems];

listArray.forEach(item => {
    item.addEventListener("click", (e) => {

        burger.classList.toggle(claseActiva);
        
        navbarMenu.classList.toggle(claseActiva);

        $target.classList.toggle(claseActiva);

    });
});

» La animación del menú al abrirlo/cerrarlo

Esta operación parecía sencilla, pero estudiando el comportamiento que tenía con Bootstrap, contaba con cierto grado de dificultad, ya que trabajaba estrechamente con jQuery.
Lo primero que se debe tener en cuenta es que display es una propiedad CSS que no se puede animar (ver “Animation Type” o “Tipo de Animación”): es decir, no se puede hacer una animación de display: none a display: block ni viceversa.
Entre lo que encontré en la documentación de Bootstrap acerca de collapse y lo que observé a través del inspector del navegador, podría describir que el comportamiento es más o menos así:

El <div> que contiene el “botón hamburguesa”, cuenta con la clase navbar-collapse. Al estar cerrado, contiene también la clase collapse. Al abrirse el menú, se agrega la clase show. Pero hay un paso “oculto”: entre collapse y collapse show, la transición se hace a través de la clase collapsing, la cual reemplaza a la clase collapse y remueve a la clase show.
Resumiendo los pasos, la transición sería: collapse >> collapsing >> collapse show para abrir el menú, y collapse show >> collapsing >> collapse para cerrarlo.

La clase collapsing anima el height desde su valor actual hasta cero, y agrega una animación ease de 0.35 segundos a la altura. Al revisar bootstrap.css (en la versión 4.6.2), esto es lo que puede verse:

.collapsing {
    position: relative;
    height: 0;
    overflow: hidden;
    transition: height 0.35s ease;
}

Al observar además la fuente de bootstrap.js (en la versión 4.6.2), puede notarse que se aprovecha el evento transitionend.

Hecho todo este análisis, para mi caso puntual, la estrategia fue combinar las bondades de CSS con JS. Aproveché además los responsive mixins de Bulma.

Lo que en Bootstrap era collapsing, aquí lo llamé desapareciendo (sí, hice una mezcla de idiomas a propósito 😄)
Hice uso de los keyframes para las animaciones de las transiciones, ya que quería lograr una transición suave.

/* Para apuntar a los dispositivos con una pantalla más estrecha que el breakpoint al que se hace referencia. $desktop = 992px;
el mixin "until" aquí es lo mismo que:
@media screen and (max-width: $dispositivo - 1px)
donde $dispositivo = $desktop
*/
@include until($desktop) {

/*
- El translateY de 0 a -20 hace que se oculte el menú: lo "lleva para arriba".
- El z-index de initial a -10 hace que los ítems del menú se muevan "detrás" del nav. De no colocarse, es notorio que estos ítems van para arriba, ya que se superponen al navbar-brand. 
- El opacity de 1 a 0 logra el efecto de desvanecimiento (fade): de opaco a transparente.
*/

    @keyframes cerrar{
        from {
            transform: translateY(0);
            z-index: initial;
            opacity: 1;
            }
        to {
            transform: translateY(-20%);
            z-index: -10;
            opacity: 0;
            }
    }  

    // Lo inverso de cerrar ;)

    @keyframes abrir {
        0% {
            transform: translateY(-20%);
            z-index: -10;
            opacity: 0;
            }
        100% {
            transform: translateY(0);
            z-index: initial;
            opacity: 1;
            }
    }

    #sideNav { //el navbar
        max-height: 0; //para evitar animar al parent, solo al child (el menú en este caso)
        & > .navbar-menu {
            position: relative;
            animation: abrir .35s;       
            &.desapareciendo {
                animation: cerrar .35s;
            }
        }
    }
}

Como utilicé @keyframes, lo interesante aquí con JS es que se puede aprovechar el evento animationend.
En esta implementación, solo se utiliza desapareciendo al cerrarse el menú.

const claseDesapareciendo = 'desapareciendo';

burger.addEventListener('click', () => {

    if (navbarMenu.classList.contains(claseActiva)){     // si se pulsó el botón para cerrar el menú...
        navbarMenu.classList.add(claseDesapareciendo); // se agrega la clase "desapareciendo"
    }
    else{
        navbarMenu.classList.add(claseActiva); //sino, se agrega la clase activa, ya que se pulsó el botón para abrir el menú.
    }

    navbarMenu.addEventListener('animationend', () => { // al terminar la animación...
        if (navbarMenu.classList.contains(claseDesapareciendo)){ // si fue la animación de cierre del menú,
            navbarMenu.classList.remove(claseActiva);           // remueve la clase activa...
            navbarMenu.classList.remove(claseDesapareciendo);   // ... y también "desapareciendo", para finalizar el ocultamiento. De hecho, si se remueve esta línea, al volver a abrir el menú, tal menú aparecerá como un destello y luego volverá a desaparecer :)
        }
    });

});

Enlaces Útiles

Hugo Theme: "Bulma Hugo Resume", basado en  Hugo Resume