Inicio / Artículos / Acordeones React accesibles para rastreo: colapsar con CSS Grid, no desmontando

Acordeones React accesibles para rastreo: colapsar con CSS Grid, no desmontando

Crea acordeones anidados de React que mantengan el contenido en el DOM para su indexación, anima la altura con filas de cuadrícula, limita a un solo elemento abierto por nivel y reinicia al cerrarlo.

1798 palabras

La mayoría de los tutoriales sobre acordeones muestran el panel solo cuando está abierto. Eso está bien para un modal, pero en una página con mucho contenido como un portafolio, un centro de documentación o una sección de Preguntas Frecuentes, significa que el texto dentro de cada sección cerrada no existe en el documento cuando un rastreador procesa la página. Esta guía reconstruye un conjunto de secciones anidadas plegables de modo que nada salga del DOM, la altura se anima suavemente sin números mágicos, la regla de “solo uno abierto” funciona correctamente en cada nivel de anidamiento, al reabrir una sección se comienza desde cero, y el punto de clic termina donde termina el texto del encabezado.

El escenario: once secciones, a tres niveles de profundidad

Imagínese un sitio personal con once secciones de nivel superior que se pueden plegar: sobre mí, disponibilidad, experiencia, portafolio, formación, idiomas, habilidades, laboratorio, descargas, ubicación y contacto. Varias de ellas contienen más secciones internas. Solo la sección de contacto tiene tres niveles, ya que los perfiles públicos, las publicaciones y los tableros de empleo se dividen cada uno en sus propios subgrupos.

Cuando la página está completamente expandida, se convierte en un muro abrumador de texto. Cuando está plegada, es fácil de escanear. Hacer que las secciones sean plegables es la opción obvia. La pregunta es cómo.

El patrón que casi todos los tutoriales enseñan es el renderizado condicional:

{isOpen && (
  <div className="content">
    {children}
  </div>
)}

Funciona, e incluso con un elemento contenedor se puede animar. Pero esto va en contra del propósito principal de una página que existe para ser encontrada.

Qué hace realmente el montaje condicional

{isOpen && ...} no oculta nada. Cuando isOpen es falso, React nunca crea ese subárbol, por lo que no hay nada en el DOM que ocultar o mostrar.

Para modales y menús desplegables ese es exactamente el comportamiento adecuado; un diálogo cerrado no debería estar presente en el documento. En una página de contenido, ocurre lo contrario. En un portafolio, las secciones plegadas son la esencia: años de experiencia, descripciones de proyectos, listas de tecnologías, responsabilidades y resultados. Cada término que un reclutador podría buscar se encuentra dentro de algo que comienza cerrado.

Google sí ejecuta JavaScript, así que esto es menos grave de lo que solía ser. Pero la página se renderiza en su estado inicial. El rastreador no hace clic en los símbolos de flecha. Todo lo que se desmonta al cargar, a efectos de indexación, no está en la página.

Plegado con estilos en lugar de renderizado

La solución consiste en tratar “collapsed” como un problema de estilo y no como una decisión de renderizado. El contenido se muestra una vez y permanece así; solo cambia su altura visible.

La forma tradicional de animar la altura es mediante max-height, lo que obliga a estimar un valor mayor que la sección más alta y aceptar un sincronización irregular, ya que la transición ocurre en todo el rango estimado en lugar de en la altura real. CSS Grid ofrece un enfoque más limpio, como se muestra aquí con las clases de Tailwind:

<div
  className={`grid transition-all duration-300 ${
    isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
  }`}
>
  <div className="overflow-hidden">{children}</div>
</div>

Por qué funciona el truco de grid-rows

Una pista de cuadrícula con tamaño 1fr se expande para adaptarse a la altura natural de su contenido, mientras que una pista con tamaño 0fr se reduce a cero. Los navegadores pueden interpolar entre estos dos valores, por lo que la transición es suave y no requiere un límite máximo. El contenedor interno con overflow-hidden es esencial: sin él, el contenido se derramaría fuera de la fila de altura cero en lugar de ser recortado.

El marcado es ahora idéntico en los estados abierto y cerrado. Solo difieren la altura calculada y la opacidad, por lo que cada palabra permanece en el documento y está disponible para indexación.

Una precaución: animar grid-template-rows es una funcionalidad relativamente reciente en los navegadores. Los motores más antiguos simplemente pasarán de un estado a otro abruptamente, así que si debe soportarlos, planifique una solución alternativa o acepte la falta de animación en esos casos. Verifique los datos de soporte actuales según su propio tráfico.

Mantener el contenido plegado fuera del orden de pestañas

Mantener el contenido en el DOM tiene un efecto secundario relacionado con la accesibilidad que merece atención. Los enlaces y botones dentro de un panel visualmente plegado aún pueden recibir el foco del teclado, y los lectores de pantalla podrían seguir anunciándolos. Al agregar el atributo inert al panel mientras está cerrado (o al menos aria-hidden además de hacer que los controles internos no puedan recibir foco), se mantiene el texto en el documento para los rastreadores, pero se evita su interacción. Combine el botón de encabezado con aria-expanded para que la tecnología de asistencia conozca el estado. El navegador también ofrece hidden="until-found", lo cual permite que el contenido siga siendo buscable mediante la función de búsqueda en la página; vale la pena evaluarlo, pero su estilo y animaciones difieren del enfoque basado en cuadrículas.

Ambito de aplicación de “solo uno abierto a la vez”

Con el funcionamiento de plegado, el siguiente requisito es el comportamiento clásico del acordeón: al abrir una sección, se cierran las demás. En un listado plano, esto implica tener un estado que almacene la clave de apertura en la parte superior y una comprobación de igualdad en cada elemento.

Las secciones anidadas rompen esto de inmediato. Una sola regla global cierra al padre en el momento en que se abre un hijo, ya que el hijo es simplemente otro elemento plegable y la regla no puede distinguir entre ellos. Al expandir un subgrupo, como los tableros de empleo de una empresa, la sección que lo contiene se cierra rápidamente debajo del cursor.

Esta restricción necesita un ámbito definido: la exclusividad aplica dentro de un conjunto de elementos hermanos bajo el mismo padre, no en toda la página. Cada elemento plegable pertenece al grupo de su padre y también crea un nuevo grupo para sus propios hijos. El grupo es una estructura sencilla que almacena la clave de apertura y su mecanismo de configuración:

type CollapseGroup = {
  openKey: string | null;
  setOpenKey: (key: string | null) => void;
};

Esa forma se comparte a través de un contexto de React, donde null significa “no está dentro de un grupo”:

const CollapseGroupContext = createContext<CollapseGroup | null>(null);

Cada nodo lee el contexto de su padre para determinar si está abierto y luego envuelve a sus hijos en un proveedor nuevo. Con tres niveles de anidamiento, el estado se encuentra en tres contextos independientes en lugar de en un mapa plano con claves compuestas como contact/profiles/boards. Esto mantiene cada nivel simple y permite una profundidad ilimitada sin necesidad de registros adicionales.

Restablecimiento del estado anidado al cerrar un padre

Un problema de usabilidad más sutil aparece solo después de usar la página durante un tiempo. Abre una sección, luego una subsección y después una subsubsección. Cierra la sección de nivel superior y lee algo distinto. Cuando vuelves a abrir esa sección principal más tarde, vuelve exactamente al estado de tres niveles que dejaste atrás.

Mantener el estado parece considerado, pero resulta desorientador. Volver a abrir algo se interpreta como empezar de nuevo, y la interfaz contradice esa expectativa. Uno ha olvidado dónde estaba; la interfaz de usuario no.

La solución es hacer que al cerrar un nodo se resuelva todo lo que está debajo de él. El proveedor es dueño de la clave abierta de su grupo:

const CollapseGroupProvider = ({ isOpen, children }) => {
  const [openKey, setOpenKey] = useState<string | null>(null);

y un efecto elimina esa clave cada vez que se cierra el propio nodo del proveedor:

  useEffect(() => {
    if (!isOpen) setOpenKey(null);
  }, [isOpen]);  // ...
};

Por qué la cascada se maneja sola

Cuando se cierra un nodo de primer nivel, su proveedor restablece la selección de segundo nivel a null. Ahora todos los nodos de segundo nivel están cerrados, lo que activa los efectos de sus proveedores, los cuales eliminan el nivel tres, y así sucesivamente. El restablecimiento se propaga a cualquier profundidad sin necesidad de recorrer explícitamente el árbol.

La desventaja es que cada nivel se procesa en un paso de renderizado separado, ya que los efectos se ejecutan después del renderizado. En unos pocos niveles esto es imperceptible. Si alguna vez observa que causa parpadeos visibles en un árbol muy profundo, una alternativa es volver a montar el proveedor hijo cambiando su key cuando el padre se cierra, lo que descarta el estado anidado en un solo paso. De cualquier manera, como el contenido permanece montado, solo se reinician las claves abiertas; el propio contenido del DOM nunca se destruye.

Reducir un objetivo de clic demasiado grande

Un problema menor puede llevar mucho tiempo diagnosticarse. Cada botón de encabezado se extiende por toda la fila:

className="flex w-full items-center justify-start gap-3 py-1 ..."

Así que toda la línea reacciona a los clics, incluso el área en blanco después del título, que se extiende casi por toda la pantalla. Intentar seleccionar texto o hacer clic en el margen hace que una sección se pliegue o despliegue de forma inesperada.

Al pasar de w-full a w-fit, el botón se ajusta al tamaño de su contenido: el emoji, el título y la flecha.

className="flex w-fit items-center justify-start gap-3 py-1 ..."

Elegir explícitamente w-fit, en lugar de simplemente eliminar w-full, es intencional. Un <button> con display: flex y ancho automático depende de cómo cada navegador dimensiona intrínsecamente los controles de formulario, y ser explícito evita depender de que ese comportamiento sea idéntico en todas partes.

Tampoco hay regresión en pantallas pequeñas. Un ancho de tipo fit-content se ajusta al espacio disponible, sin superar nunca el contenedor, por lo que un título largo en un teléfono sigue ajustándose de la misma manera que antes.

Prueba de la cascada de estados

La lógica basada en estados anidados impulsada por efectos es del tipo que parece correcta en las pruebas pero falla al usarse. Antes de enviarlo, vale la pena renderizar el proveedor con React en jsdom y verificar los casos importantes:

  • Al abrir L1, luego L2 y después L3, los tres permanecen abiertos.
  • Al cerrar L1, los tres quedan cerrados.
  • Al volver a abrir L1, solo se abre el nivel 1, mientras que los niveles 2 y 3 permanecen cerrados.
  • Al abrir un elemento hermano de L1, toda la rama de L1 queda cerrada.
  • Al cerrar y volver a abrir toda la sección, todo lo que está debajo de ella queda cerrado.

El tercer caso es precisamente para el cual existe la función de reinicio, y es el más propenso a dar errores si se confía ciegamente en el código. También vale la pena añadir una verificación para asegurarse de que el contenido recogido siga estando presente en el marcado renderizado, ya que esa es la propiedad de la que depende todo el diseño.

Conclusión

Ninguna de estas opciones aparece en una captura de pantalla. Los visitantes no se darán cuenta de que el texto plegado sigue estando en el DOM, de que volver a abrir un bloque comienza desde cero o de que el encabezado deja de aceptar clics donde termina el texto. Cuando se hace bien, la única sensación es que nada resulta molesto, y los motores de búsqueda ven toda la página.

  • Desmonta elementos que no deberían existir cuando están cerrados, como modales y menús; pliega con CSS el contenido que siempre debe formar parte de la página.
  • Anima la altura con grid-template-rows entre 0fr y 1fr y utiliza un hijo con overflow-hidden en lugar de estimar una max-height.
  • Haz que los paneles plegados sean inertes para que el contenido oculto sea indexable pero no enfocable.
  • Aplique el estado del acordeón a los grupos hermanos con un contexto por nivel, y elimine el estado del hijo cuando se cierra su padre.
  • Ajuste el tamaño de los encabezados interactivos al de su contenido, y pruebe las transiciones de estado en lugar de observarlas visualmente.
  • Para conocer técnicas relacionadas sobre cómo hacer que el contenido fuera de la pantalla se renderice de forma económica sin perder la posibilidad de indexación, consulte evitar el renderizado fuera de la pantalla con content-visibility.