← Todos los artículos

API y helpers

Crear una capa de auditoría de accesibilidad en vivo que puedes inyectar en cualquier sitio

Lighthouse y axe son excelentes — y también son un cambio de contexto. Abres DevTools, ejecutas la auditoría, lees el informe, vuelves a la página. Para una primera pasada rápida mientras construyes, quieres los problemas dibujados sobre la propia página: un contorno rojo alrededor de cada imagen sin texto alt, una insignia sobre cada campo sin etiqueta. Este artículo construye exactamente eso como una regla JavaScript que puedes inyectar en cualquier sitio con JustZix.

Qué es esta capa — y qué no es

Esto es un test de humo de primera pasada. Detecta los errores baratos, comunes y mecánicos — los que representan una gran parte de los fallos reales de accesibilidad — y los muestra visualmente para que no puedas pasarlos por alto. No es un sustituto de axe-core o Lighthouse: no comprueba la semántica ARIA en profundidad, el orden del foco ni el comportamiento del lector de pantalla. Piénsalo como el linter que ejecutas constantemente, no la auditoría completa que ejecutas antes de publicar.

Todo el conjunto es una regla JS. Inyéctala, echa un vistazo a la página, arregla lo que se ilumine, reinyecta.

El esqueleto

Recogeremos los problemas en un array, dibujaremos un contorno más una insignia por cada uno y mostraremos un contador resumen. Empieza con el andamiaje.

(() => {
  // Clean up a previous run
  document.querySelectorAll('.jz-a11y').forEach(n => n.remove());

  const issues = [];

  function flag(el, label, color) {
    if (!el || !el.getBoundingClientRect) return;
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) return; // skip hidden
    issues.push({ label });

    const box = document.createElement('div');
    box.className = 'jz-a11y';
    Object.assign(box.style, {
      position: 'absolute',
      left: (r.left + scrollX) + 'px',
      top: (r.top + scrollY) + 'px',
      width: r.width + 'px',
      height: r.height + 'px',
      outline: '2px solid ' + color,
      background: color + '22',
      zIndex: 2147483000,
      pointerEvents: 'none',
      boxSizing: 'border-box',
    });

    const tag = document.createElement('span');
    tag.textContent = label;
    Object.assign(tag.style, {
      position: 'absolute', left: 0, top: '-18px',
      font: '11px/1.4 system-ui, sans-serif',
      background: color, color: '#fff',
      padding: '1px 5px', whiteSpace: 'nowrap',
    });
    box.appendChild(tag);
    document.body.appendChild(box);
  }

Una llamada a flag() por cada elemento problemático. Registra el problema y pinta una caja posicionada de forma absoluta sobre el elemento con una insignia etiquetada. Todo lleva la clase jz-a11y para que la siguiente ejecución pueda borrarlo.

Comprobación 1 — imágenes sin alt

Una <img> sin ningún atributo alt es un fallo; alt="" es válido (marca la imagen como decorativa), así que solo señalamos un atributo genuinamente ausente.

  document.querySelectorAll('img').forEach(img => {
    if (!img.hasAttribute('alt')) {
      flag(img, 'img: no alt', '#e11d48');
    }
  });

Comprobación 2 — campos de formulario sin etiqueta

Un campo necesita un nombre accesible. Eso puede venir de un <label> envolvente o asociado, de un aria-label o de aria-labelledby. Si no hay ninguno, el campo es inutilizable con un lector de pantalla.

  const fields = 'input:not([type=hidden]):not([type=submit])'
    + ':not([type=button]), select, textarea';

  document.querySelectorAll(fields).forEach(el => {
    const byFor = el.id &&
      document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
    const wrapped = el.closest('label');
    const aria = el.getAttribute('aria-label')
      || el.getAttribute('aria-labelledby')
      || el.getAttribute('title');

    if (!byFor && !wrapped && !aria) {
      flag(el, 'input: no label', '#f59e0b');
    }
  });

Comprobación 3 — enlaces y botones vacíos

Un enlace o botón sin contenido de texto y sin etiqueta accesible se anuncia simplemente como «enlace» o «botón». Esto es extremadamente común con los botones de solo icono.

  document.querySelectorAll('a, button').forEach(el => {
    const text = (el.textContent || '').trim();
    const aria = el.getAttribute('aria-label')
      || el.getAttribute('title');
    const img = el.querySelector('img[alt]:not([alt=""])');
    const hasName = text || aria || img;

    if (!hasName) {
      const what = el.tagName === 'A' ? 'link' : 'button';
      flag(el, 'empty ' + what, '#8b5cf6');
    }
  });

Comprobación 4 — texto con contraste bajo

Las matemáticas completas de contraste de WCAG son complejas, pero una comprobación de luminancia relativa detecta a los peores infractores. Calcula la luminancia para el color del texto y el fondo resuelto, y luego toma la ratio de contraste.

  function lum(rgb) {
    const c = rgb.map(v => {
      v /= 255;
      return v <= 0.03928
        ? v / 12.92
        : Math.pow((v + 0.055) / 1.055, 2.4);
    });
    return 0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2];
  }
  function parse(str) {
    const m = str.match(/\d+(\.\d+)?/g);
    return m ? m.slice(0, 3).map(Number) : null;
  }
  function bgOf(el) {
    let n = el;
    while (n) {
      const bg = getComputedStyle(n).backgroundColor;
      if (bg && !/rgba?\(0, 0, 0, 0\)|transparent/.test(bg)) {
        return parse(bg);
      }
      n = n.parentElement;
    }
    return [255, 255, 255];
  }

  document.querySelectorAll('p, span, a, li, h1, h2, h3, h4, td')
    .forEach(el => {
      if (!(el.textContent || '').trim()) return;
      const fg = parse(getComputedStyle(el).color);
      const bg = bgOf(el);
      if (!fg || !bg) return;
      const l1 = lum(fg) + 0.05, l2 = lum(bg) + 0.05;
      const ratio = l1 > l2 ? l1 / l2 : l2 / l1;
      if (ratio < 4.5) {
        flag(el, 'contrast ' + ratio.toFixed(1) + ':1', '#0ea5e9');
      }
    });

El umbral de 4.5:1 es el mínimo WCAG AA para texto de tamaño normal. El texto grande pasa con 3:1, así que espera algún falso positivo en encabezados grandes — revísalos a ojo.

Comprobación 5 — saltos en el orden de encabezados y lang ausente

Los encabezados no deberían saltarse niveles — un <h2> seguido directamente de un <h4> rompe el esquema del documento. Y el <html> raíz necesita un lang para que los lectores de pantalla elijan la pronunciación correcta.

  let prev = 0;
  document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h => {
    const level = +h.tagName[1];
    if (prev && level > prev + 1) {
      flag(h, 'jump h' + prev + '→h' + level, '#db2777');
    }
    prev = level;
  });

  if (!document.documentElement.getAttribute('lang')) {
    issues.push({ label: 'html: no lang' });
  }

El contador resumen

Por último, una insignia fija en la esquina con el total. Es la puntuación de un vistazo; los contornos son el detalle.

  const sum = document.createElement('div');
  sum.className = 'jz-a11y';
  sum.textContent = issues.length
    ? issues.length + ' a11y issue(s)'
    : 'No quick a11y issues found';
  Object.assign(sum.style, {
    position: 'fixed', right: '12px', bottom: '12px',
    font: '13px/1.5 system-ui, sans-serif',
    background: issues.length ? '#e11d48' : '#16a34a',
    color: '#fff', padding: '6px 12px', borderRadius: '6px',
    zIndex: 2147483600, pointerEvents: 'none',
  });
  document.body.appendChild(sum);

  console.table(issues);
})();

El console.table(issues) del final también vuelca una lista ordenada a la Consola de Salida, así que tienes tanto la capa visual como un resumen copiable.

Cómo usarla en el día a día

Mira también

Una capa de auditoría en vivo convierte la accesibilidad de una lista de comprobación que olvidas en algo que no puedes ignorar. Instala JustZix, pega el script y deja que la página te diga qué está mal.

Valora este artículo

Sin valoraciones — sé el primero.

Pruébalo tú mismo

Instala JustZix y pega cualquier snippet de este artículo. Dos minutos de cero a una regla funcionando en todos tus dispositivos.

Obtener JustZix

Funciones · Cómo funciona · Ejemplos · Casos de uso