← Wszystkie wpisy

API i helpers

Zbuduj nakładkę audytu dostępności na żywo, którą wstrzykniesz wszędzie

Lighthouse i axe są doskonałe — i są też przełączeniem kontekstu. Otwierasz DevTools, uruchamiasz audyt, czytasz raport, wracasz do strony. Do szybkiego pierwszego przejścia w trakcie budowania chcesz, aby problemy były narysowane na samej stronie: czerwony kontur wokół każdego obrazu bez tekstu alternatywnego, plakietka nad każdym nieopisanym polem. Ten artykuł buduje dokładnie to jako regułę JavaScript, którą za pomocą JustZix wstrzykniesz na dowolną stronę.

Czym ta nakładka jest — a czym nie jest

To test dymny pierwszego przejścia. Wyłapuje tanie, częste, mechaniczne błędy — te, które odpowiadają za dużą część prawdziwych niepowodzeń dostępności — i pokazuje je wizualnie, abyś nie mógł ich przeoczyć. Nie jest zamiennikiem axe-core czy Lighthouse: nie testuje głęboko semantyki ARIA, kolejności fokusu ani zachowania czytnika ekranu. Traktuj to jak linter, który uruchamiasz nieustannie, a nie pełny audyt, który uruchamiasz przed wydaniem.

Całość to jedna reguła JS. Wstrzyknij ją, rzuć okiem na stronę, napraw to, co się zapali, wstrzyknij ponownie.

Szkielet

Będziemy zbierać problemy do tablicy, rysować kontur plus plakietkę dla każdego i pokazywać licznik podsumowania. Zacznij od rusztowania.

(() => {
  // 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);
  }

Jedno wywołanie flag() na element problematyczny. Rejestruje problem i maluje absolutnie pozycjonowane pudełko nad elementem z opisaną plakietką. Wszystko nosi klasę jz-a11y, aby następne uruchomienie mogło to wyczyścić.

Sprawdzenie 1 — obrazy bez alt

<img> w ogóle bez atrybutu alt to niepowodzenie; alt="" jest poprawne (oznacza obraz jako dekoracyjny), więc flagujemy tylko faktycznie brakujący atrybut.

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

Sprawdzenie 2 — pola formularza bez etykiety

Pole wejściowe potrzebuje dostępnej nazwy. Może ona pochodzić z opakowującego lub powiązanego <label>, z aria-label albo z aria-labelledby. Jeśli żadnej nie ma, pole jest bezużyteczne dla czytnika ekranu.

  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');
    }
  });

Sprawdzenie 3 — puste linki i przyciski

Link lub przycisk bez treści tekstowej i bez dostępnej etykiety jest ogłaszany po prostu jako „link” lub „przycisk”. To wyjątkowo częste przy przyciskach z samą ikoną.

  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');
    }
  });

Sprawdzenie 4 — tekst o niskim kontraście

Pełna matematyka kontrastu WCAG jest złożona, ale sprawdzenie względnej luminancji wyłapuje najgorszych winowajców. Oblicz luminancję dla koloru tekstu i rozstrzygniętego tła, a potem weź współczynnik kontrastu.

  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');
      }
    });

Próg 4.5:1 to minimum WCAG AA dla tekstu o normalnym rozmiarze. Duży tekst dostaje przepustkę przy 3:1, więc spodziewaj się kilku fałszywych alarmów na dużych nagłówkach — przyjrzyj się im okiem.

Sprawdzenie 5 — skoki kolejności nagłówków i brak lang

Nagłówki nie powinny przeskakiwać poziomów — <h2> bezpośrednio po którym idzie <h4> psuje strukturę dokumentu. A główny <html> potrzebuje atrybutu lang, aby czytniki ekranu wybrały właściwą wymowę.

  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' });
  }

Licznik podsumowania

Na koniec stała plakietka w rogu z sumą. To wynik na pierwszy rzut oka; kontury to szczegóły.

  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);
})();

console.table(issues) na końcu zrzuca też uporządkowaną listę do Output Console, więc masz zarówno wizualną nakładkę, jak i podsumowanie do skopiowania.

Jak używać tego na co dzień

Zobacz też

Nakładka audytu na żywo zmienia dostępność z listy kontrolnej, o której zapominasz, w coś, czego nie da się zignorować. Zainstaluj JustZix, wklej skrypt i pozwól stronie powiedzieć Ci, co jest nie tak.

Oceń ten wpis

Brak ocen — oceń jako pierwszy.

Wypróbuj samodzielnie

Zainstaluj JustZix i wklej dowolny snippet z tego artykułu. Dwie minuty od zera do działającej reguły na wszystkich Twoich urządzeniach.

Pobierz JustZix

Funkcje · Jak to działa · Przykłady · Zastosowania