Todos los artículos

Accesibilidad

WCAG 2.1 AA en un sitio Elementor: lo que hubo que corregir

Por Rolando Escobar, desarrollador WordPress para agencias · · 6 min de lectura

Elementor te lleva una parte del camino hacia WCAG 2.1 AA y deja huecos que solo aparecen cuando sueltas el mouse. Este mes reconstruí rolandowp.com con Elementor 4.3 y sus elementos atómicos V4, y estos son los arreglos que dejaron cada página con 100 de accesibilidad en Lighthouse y cero errores en axe-core.

Ninguno necesitó un plugin. Todo vive en un tema hijo de Hello Elementor: unas cuantas acciones, dos shortcodes, un poco de JavaScript y una capa de CSS. Ten a mano la referencia rápida de WCAG 2.1 mientras lees; cada arreglo corresponde a un criterio de ahí.

Un solo anillo de foco para todo lo interactivo

Quien navega con teclado necesita ver dónde está (2.4.7 Foco visible). Los widgets de Elementor, el tema padre y el CSS viejo pintan el foco cada uno a su manera, y mi sitio todavía tenía un fragmento olvidado que les quitaba el contorno a los botones. Lo borré y cambié todos los estilos de foco por un solo conjunto de reglas:

:root {
	--rw-focus: #1B4FD8;
	--rw-focus-on-dark: #8AB4FF;
}

/* 2.4.7: un anillo de alto contraste para todo lo que puede recibir foco. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
	outline: 3px solid var(--rw-focus) !important;
	outline-offset: 3px !important;
}

/* En secciones azul marino el anillo azul baja de 3:1: se cambia a azul claro. */
:where(.section-dark, .section-dark *):focus-visible {
	outline-color: var(--rw-focus-on-dark) !important;
}

/* Quita el anillo solo con clics de mouse, nunca con teclado. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus:not(:focus-visible) {
	outline: none;
}

:where() evita que la lista de elementos sume especificidad, así que cada regla pesa lo mismo que una sola pseudoclase. El !important del contorno es la única excepción a propósito: ningún estilo de widget puede quitar el anillo.

El color importa tanto como el grosor. #1B4FD8 sobre blanco da 6.65:1, pero sobre el azul marino #0B1220 de mis secciones oscuras baja a 2.81:1, por debajo del 3:1 que 1.4.11 Contraste no textual pide a los indicadores de foco. El azul claro llega a 8.96:1 sobre el mismo fondo.

Un enlace de salto que lleve a algún lado

Hello Elementor imprime un enlace «Saltar al contenido» que apunta a #content. En las páginas con la plantilla de ancho completo de Elementor y en las plantillas single del Theme Builder, nada tenía ese ID. El enlace no hacía nada y, además, la página no tenía un landmark <main> (2.4.1 Evitar bloques). Elementor dispara acciones justo antes y después de pintar el contenido en ambos casos, y con eso basta:

<?php
/**
 * Envuelve el contenido de Elementor en un solo <main> para el enlace de salto.
 * El contador evita un <main> doble si se disparan los dos pares de hooks.
 */
function rw_main_depth( int $change = 0 ): int {
	static $depth = 0;
	$depth += $change;
	return $depth;
}

function rw_open_main() {
	if ( 1 === rw_main_depth( 1 ) ) {
		echo '<main id="content" class="rw-main" tabindex="-1">';
	}
}

function rw_close_main() {
	if ( 0 === rw_main_depth( -1 ) ) {
		echo '</main>';
	}
}

// Plantilla de página "Elementor ancho completo".
add_action( 'elementor/page_templates/header-footer/before_content', 'rw_open_main' );
add_action( 'elementor/page_templates/header-footer/after_content', 'rw_close_main' );

// Plantillas single del Theme Builder.
add_action( 'elementor/theme/before_do_single', 'rw_open_main' );
add_action( 'elementor/theme/after_do_single', 'rw_close_main' );

En mi sitio son dos funciones simples. El contador es una protección que vale la pena agregar: una plantilla single del Theme Builder con diseño de ancho completo puede disparar los dos pares, y dos <main> anidados son otro error de axe. tabindex="-1" permite que el navegador mueva el foco al landmark, así el siguiente Tab sigue desde el contenido y no desde el header. El landmark no es un control, así que el CSS le quita el anillo a .rw-main:focus y hace visible el enlace de salto cuando recibe el foco.

Menús con nombre propio

El widget de menú de V3 se veía mal dentro de los contenedores V4, así que lo cambié por un shortcode. Los shortcodes son una salida subestimada, en Elementor y fuera de él: en el sitio de la Sociedad Dental de Orange County, que construí con bloques de WordPress a partir de un diseño de Figma, funciones como las búsquedas filtradas por etiquetas son plugins a medida insertados como shortcodes. Aquí lo son el menú y el selector de idioma:

<?php
/**
 * [rw_primary_nav]: el menú del header como un <nav> con nombre, sin JavaScript.
 */
function rw_primary_nav_shortcode() {
	$es   = function_exists( 'pll_current_language' ) && 'es' === pll_current_language();
	$menu = wp_nav_menu(
		[
			'menu'                 => $es ? 'header-menu-es' : 'header-menu',
			'container'            => 'nav',
			'container_aria_label' => $es ? 'Principal' : 'Main',
			'menu_class'           => 'rw-nav__list',
			'depth'                => 1,
			'fallback_cb'          => false,
			'echo'                 => false,
		]
	);

	return $menu ? $menu : '';
}
add_shortcode( 'rw_primary_nav', 'rw_primary_nav_shortcode' );

/**
 * [rw_lang_switcher]: enlaces simples dentro de su propio <nav> con nombre.
 */
function rw_lang_switcher_shortcode() {
	if ( ! function_exists( 'pll_the_languages' ) ) {
		return '';
	}
	$languages = pll_the_languages( [ 'raw' => 1, 'hide_if_empty' => 0 ] );
	$label     = 'es' === pll_current_language() ? 'Idioma' : 'Language';
	$items     = '';

	foreach ( (array) $languages as $lang ) {
		$items .= sprintf(
			'<li><a href="%1$s" lang="%2$s" hreflang="%2$s"%3$s>%4$s</a></li>',
			esc_url( $lang['url'] ),
			esc_attr( str_replace( '_', '-', $lang['locale'] ) ), // es_ES pasa a es-ES.
			empty( $lang['current_lang'] ) ? '' : ' aria-current="true"',
			esc_html( $lang['name'] )
		);
	}

	return sprintf( '<nav class="rw-lang" aria-label="%s"><ul>%s</ul></nav>', esc_attr( $label ), $items );
}
add_shortcode( 'rw_lang_switcher', 'rw_lang_switcher_shortcode' );

Dos <nav> en la misma página necesitan nombres distintos; si no, quien usa lector de pantalla oye «navegación» dos veces sin poder distinguirlas. container_aria_label existe en wp_nav_menu() desde WordPress 5.5. El atributo lang en «English» le indica al lector de pantalla que cambie la pronunciación (3.1.2 Idioma de las partes), y aria-current="true" marca el idioma activo sin depender solo de la negrita.

En el teléfono no hay menú hamburguesa. El menú pasa a una segunda fila siempre visible: no hay botón desplegable que hacer accesible ni enlaces escondidos.

Formularios: etiquetas, propósito y avisos

El formulario de contacto tiene etiquetas visibles (no campos que solo dependen del placeholder) y una nota que avisa que todos los campos son obligatorios (3.3.2 Etiquetas o instrucciones). Los campos de nombre y correo necesitan autocomplete="name" y autocomplete="email" para cumplir 1.3.5 Identificar el propósito de la entrada, pero el input de V4 solo activa o desactiva el autocompletado. Unas líneas en el footer ponen los valores correctos según el ID del campo:

<?php
/**
 * 1.3.5: los inputs de formularios V4 solo activan o desactivan autocomplete,
 * así que se agregan los valores reales usando los IDs definidos en el widget.
 */
function rw_contact_form_autocomplete() {
	$js = <<<'JS'
(() => {
	const tokens = { 'contact-name': 'name', 'contact-email': 'email' };
	for (const [id, token] of Object.entries(tokens)) {
		document.getElementById(id)?.setAttribute('autocomplete', token);
	}
})();
JS;
	wp_print_inline_script_tag( $js );
}
add_action( 'wp_footer', 'rw_contact_form_autocomplete', 100 );

Los mensajes de éxito y de error están en regiones aria-live, así que el lector de pantalla anuncia el resultado sin mover el foco (4.1.3 Mensajes de estado).

Después apareció la trampa que encontré más tarde. Los formularios V4 se renderizan con la clase .e-form-base, no .e-form, así que todas las reglas que había escrito para .e-form nunca se aplicaron. Ni un error ni una advertencia. Apuntar a los dos nombres lo resuelve y funciona con cualquiera de los dos marcados:

/* Los formularios V4 usan .e-form-base; .e-form por sí solo no coincide con nada. */
:is(.e-form, .e-form-base) input:not([type="checkbox"]):not([type="radio"]),
:is(.e-form, .e-form-base) textarea {
	color: var(--ink, #0F172A) !important;
	background-color: var(--surface, #FFFFFF) !important;
	border: 1.5px solid var(--line-strong, #7B8AA3) !important; /* 3.50:1 sobre blanco */
	border-radius: 0.5rem !important;
	min-height: 3rem;
}

:is(.e-form, .e-form-base) :is(input, textarea)::placeholder {
	color: var(--rw-placeholder, #5B6B82); /* 5.43:1 sobre blanco */
	opacity: 1;
}

:is(.e-form, .e-form-base) [aria-invalid="true"] {
	border-color: var(--rw-danger, #B42318) !important;
}

Contraste, revisado con un script

Cada par de texto y fondo del sitio pasa AA: 4.5:1 para el texto normal y 3:1 para el texto grande (1.4.3 Contraste (mínimo)). Los bordes de los campos llegan al menos a 3:1. Revisé los pares con un script pequeño que calcula la relación WCAG de cada combinación de variables globales; volver a correrlo después de cambiar la paleta es más rápido que ir color por color en un verificador de contraste. Como referencia, el texto #475569 sobre blanco da 7.58:1 y el borde de los campos da 3.50:1.

La semántica que el constructor no agrega por ti

  • Los datos de cada proyecto (cliente, rol, stack) salen de un shortcode como una lista <dl>, así cada etiqueta queda unida a su valor (1.3.1 Información y relaciones).
  • La grilla de proyectos tiene semántica de lista, así que el lector de pantalla anuncia cuántos proyectos hay antes de leerlos.
  • Los encabezados deben seguir el esquema de la página: un h1 por página y luego h2 y h3. En el elemento de encabezado de V4 la etiqueta es un ajuste aparte del estilo, así que revisa la etiqueta de cada encabezado en lugar de fiarte del tamaño visual.

Movimiento y reflow

El CSS respeta prefers-reduced-motion: las animaciones, las transiciones y el desplazamiento suave quedan casi en cero para quien pide menos movimiento. En rigor, el criterio relacionado (2.3.3) es AAA, pero la regla cuesta una docena de líneas.

1.4.10 Reflow sí es AA, y es el que más se pasa por alto. El diseño tiene que funcionar a 320 píxeles CSS de ancho sin scroll horizontal. Pon el viewport de DevTools en 320px, recorre cada página y busca URLs largas, correos, tablas y contenedores de ancho fijo que se salgan del borde.

Resultados, y lo que las herramientas no te dicen

Después de estos cambios, Lighthouse da 100 de accesibilidad en todas las páginas y axe-core reporta cero errores de WCAG 2.1 AA en modo claro y en modo oscuro. Aun así, las revisiones automáticas cubren solo una parte de WCAG. No pueden decirte si el orden del foco tiene sentido, si un mensaje de error ayuda o si el enlace de salto cae donde esperas. Recorre cada plantilla con Tab, activa el enlace de salto y envía el formulario vacío antes de darlo por terminado.

Qué vigilar

  • Fragmentos viejos en Custom Code de Elementor, el Personalizador o un plugin de snippets que pongan outline: none en botones o enlaces.
  • Nombres de clase que cambian entre versiones de Elementor, como .e-form y .e-form-base. Inspecciona el DOM renderizado antes de culpar a la especificidad.
  • Un segundo <main>. Corre document.querySelectorAll('main').length en la consola en cada plantilla; debe devolver 1.
  • Los banners de terceros. Mi banner de cookies enfoca su propio contenedor al cargar; ese contenedor no es un control, así que el sitio le quita el anillo y lo deja en los botones del banner.
  • Cada modo de color que publiques. Si ofreces modo oscuro, vuelve a correr axe con el modo activado.

Este es el tipo de trabajo que hago para clientes: Accesibilidad en WordPress