Tworzysz dedykowane motywy i chcesz, aby Twoje customowe bloki Gutenberga, oparte na środowisku React, były w pełni dostępne? Ten poradnik pokaże Ci, jak samodzielnie zaimplementować kluczowe mechanizmy WCAG 2.2 na poziomie kodu – bez polegania na gotowych, ociężałych wtyczkach, które tylko degradują wydajność.
Tworzenie wydajnych i dostępnych stron to obecnie standard, a nie opcja. Zamiast instalować kolejne nakładki na CMS, które często wprowadzają więcej problemów z wydajnością niż pożytku, jako programiści powinniśmy rozwiązywać problemy u podstaw. Wykorzystując PHP 8.3+, natywne mechanizmy WordPressa oraz czysty JavaScript i React, możemy zbudować komponenty, które bezbłędnie przechodzą audyty dostępności.
Zanim przejdziemy do kodu, warto upewnić się, jak wyglądają wymogi prawne dla Twojego klienta. Jeśli nie masz pewności, przeczytaj nasz poradnik w dziale FAQ, wyjaśniający kogo obowiązuje WCAG w Polsce. Jeśli Twój projekt podlega pod Europejski Akt o Dostępności (EAA), wdrożenie poniższych mechanizmów to absolutne minimum.
Dlaczego natywny Gutenberg potrzebuje pomocy z WCAG 2.2?
Chociaż rdzeń WordPressa jest stale poprawiany pod kątem dostępności, tworzenie własnych, zaawansowanych bloków (np. niestandardowych modali, dynamicznych filtrów AJAX czy rozbudowanych akordeonów) zrzuca pełną odpowiedzialność za WCAG na barki programisty. Użytkownicy korzystający z czytników ekranu (NVDA, JAWS, VoiceOver) lub poruszający się wyłącznie za pomocą klawiatury potrzebują wyraźnych punktów orientacyjnych, zamknięcia w obrębie otwartego okna dialogowego oraz informacji o dynamicznych zmianach na stronie.
Brak tych elementów to najszybsza droga do oblania audytu WCAG 2.2 na poziomie AA. Przyjrzyjmy się trzem fundamentalnym technikom, które musisz znać.
1. Skip-links: Niewidzialny, ale kluczowy skrót
Mechanizm „skip-links” (przejdź do głównej treści) to wymóg kryterium sukcesu 2.4.1 (Bypass Blocks). Umożliwia użytkownikom klawiatury ominięcie powtarzającej się nawigacji i skok bezpośrednio do treści (czyli np. do naszego głównego kontenera wyrenderowanego przez blok Gutenberga).
Zamiast polegać na wtyczkach motywu, dodajmy ten mechanizm ręcznie w plikach motywu, wykorzystując natywne hooki WordPressa.
<?php
// W pliku functions.php lub odpowiednim pliku klas motywu (np. w oparciu o PHP 8.3)
add_action('wp_body_open', 'ds_insert_skip_link');
function ds_insert_skip_link(): void {
echo '<a class="skip-link screen-reader-text" href="#primary">' . esc_html__('Przejdź do głównej treści', 'designsolutions') . '</a>';
}
?>
Aby link pojawiał się tylko wtedy, gdy otrzyma „focus” z klawiatury (klawisz Tab), wymaga odrobiny CSS (np. z wykorzystaniem Tailwind CSS, jeśli to Twój główny stack, lub czystego CSS):
/* Niezbędny CSS dla klasy screen-reader-text */
.screen-reader-text {
border: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
word-wrap: normal !important;
}
.screen-reader-text:focus {
background-color: #f1f1f1;
border-radius: 3px;
box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.6);
clip: auto !important;
clip-path: none;
color: #21759b;
display: block;
font-size: 14px;
font-weight: 700;
height: auto;
left: 5px;
line-height: normal;
padding: 15px 23px 14px;
text-decoration: none;
top: 5px;
width: auto;
z-index: 100000;
}
Ważne: Pamiętaj, aby główny kontener na Twojej stronie (np. w pliku page.php lub index.php) posiadał odpowiednie ID, w tym wypadku <main id="primary">.
2. Focus-trap w customowych blokach modali
Tworząc własny blok modalu w Gutenbergu (np. dla formularza kontaktowego lub powiększenia galerii), musisz uwięzić „focus” klawiatury wewnątrz otwartego okna. W przeciwnym razie użytkownik naciskający klawisz Tab zacznie wędrować po ukrytych elementach pod spodem strony, co łamie kryterium WCAG 2.4.3 (Focus Order).
Oto jak zbudować lekki i niezawodny focus-trap w czystym JavaScript (Vanilla JS), który możesz zainicjować po stronie frontendu (np. w pliku view.js Twojego bloku).
// Plik: src/blocks/custom-modal/view.js
document.addEventListener('DOMContentLoaded', () => {
const modals = document.querySelectorAll('.wp-block-ds-custom-modal');
modals.forEach(modal => {
const triggerBtn = modal.querySelector('.ds-modal-trigger');
const closeBtn = modal.querySelector('.ds-modal-close');
const modalDialog = modal.querySelector('.ds-modal-dialog');
if (!triggerBtn || !modalDialog) return;
// Znajdź wszystkie elementy mogące przyjąć focus
const focusableElementsString = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex="0"], [contenteditable]';
let focusableElements = [];
let firstTabStop;
let lastTabStop;
const updateFocusableElements = () => {
focusableElements = Array.from(modalDialog.querySelectorAll(focusableElementsString));
firstTabStop = focusableElements[0];
lastTabStop = focusableElements[focusableElements.length - 1];
};
const trapTabKey = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) { // Shift + Tab
if (document.activeElement === firstTabStop) {
e.preventDefault();
lastTabStop.focus();
}
} else { // Tab
if (document.activeElement === lastTabStop) {
e.preventDefault();
firstTabStop.focus();
}
}
}
if (e.key === 'Escape') {
closeModal();
}
};
const openModal = () => {
modalDialog.setAttribute('aria-hidden', 'false');
modalDialog.classList.add('is-open');
document.body.style.overflow = 'hidden'; // Zapobiega scrollowaniu tła
updateFocusableElements();
if (firstTabStop) firstTabStop.focus();
modalDialog.addEventListener('keydown', trapTabKey);
};
const closeModal = () => {
modalDialog.setAttribute('aria-hidden', 'true');
modalDialog.classList.remove('is-open');
document.body.style.overflow = '';
modalDialog.removeEventListener('keydown', trapTabKey);
triggerBtn.focus(); // Zwraca focus do przycisku, który otworzył modal
};
triggerBtn.addEventListener('click', openModal);
if (closeBtn) {
closeBtn.addEventListener('click', closeModal);
}
});
});
Powyższy skrypt nie polega na bibliotekach jQuery. Dynamicznie zbiera wszystkie interaktywne węzły (odświeżane przy otwarciu na wypadek asynchronicznego ładowania zawartości). Zwróć uwagę na obsługę klawisza Escape (wymóg dla modali) oraz poprawne przeniesienie focusu z powrotem na triggerBtn po zamknięciu – to krytyczny, często pomijany detal.
3. Aria-live: Komunikacja dynamicznych stanów (React)
Tworzenie nowoczesnych stron z blokami React często wiąże się z dynamiczną zmianą treści, do której nie dochodzi po przeładowaniu strony. Przykład: dynamiczne filtry portfolio lub załadowanie wyników wyszukiwania AJAX. Widzący użytkownik zauważy zmianę od razu. Użytkownik z czytnikiem ekranu potrzebuje atrybutu aria-live.
Zarządzanie stanem aria-live z poziomu Reacta w edytorze bloków (lub na frontendzie, jeśli korzystasz z interaktywnych wyszukiwarek) jest proste. Używamy odpowiednich ról i atrybutów.
Poniżej znajduje się przykład implementacji „live regionu” w komponencie React, np. przy dynamicznym wyświetlaniu statusu ładowania formularza lub filtrowania wpisów:
// Przykład użycia aria-live w bloku Gutenberga lub interaktywnym froncie (np. WordPress Interactivity API)
import { useState, useEffect } from '@wordpress/element';
const DynamicFilterResults = ({ results, isLoading }) => {
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
if (isLoading) {
setAnnouncement('Ładowanie nowych wyników...');
} else if (results.length > 0) {
setAnnouncement(`Znaleziono ${results.length} wyników.`);
} else {
setAnnouncement('Brak wyników do wyświetlenia.');
}
}, [isLoading, results]);
return (
<div className="ds-filter-container">
{/* Niewidoczny dla oka kontener informujący czytniki ekranu */}
<div
className="screen-reader-text"
aria-live="polite"
aria-atomic="true"
>
{announcement}
</div>
{/* Faktyczny widok wyników */}
<div className="ds-results-grid">
{isLoading ? (
<span className="spinner"></span>
) : (
results.map(item => <ResultItem key={item.id} data={item} />)
)}
</div>
</div>
);
};
Wykorzystanie aria-live="polite" gwarantuje, że czytnik ekranu dokończy czytanie aktualnego elementu, a dopiero potem ogłosi zmianę stanu, nie irytując i nie rozpraszając użytkownika. Atrybut aria-atomic="true" zapewnia, że czytnik odczyta całą treść zaktualizowanego kontenera (całe zdanie), a nie tylko podmienione słowo.
Najczęstsze błędy i na co uważać
Praca z dostępnością w customowym kodzie wymaga precyzji. Oto trzy błędy, które najczęściej obserwujemy przy audytach zewnętrznych projektów:
- Zamykanie modali wymuszające reset strony: Po naciśnięciu
Escw modalu, focus ląduje na początku dokumentu (zamiast wrócić na przycisk, który ów modal wyzwolił). Powoduje to dezorientację i konieczność ponownej nawigacji od zera. - Nadużywanie
aria-live="assertive": Używajassertivewyłącznie do alertów i błędów krytycznych, które wymagają natychmiastowej akcji użytkownika. Do standardowych powiadomień i przeładowań używajpolite. - Ukrywanie elementów z fokusem za pomocą
display: none: Jeśli zwiniesz element np. w akordeonie bez uprzedniego usunięcia go z tab-orderu (lub właśnie bez zmiany atrybutudisplaybądź ustawieniainert), użytkownik klawiatury nadal „wejdzie” na ukryty link, „znikając” z ekranu.
Podsumowanie i checklist dla developera
Tworząc własne bloki Gutenberga pod rygor WCAG 2.2, nie potrzebujesz wtyczek typu „Accessibility Widget” (które i tak nie naprawiają problemów w źródle, a jedynie je maskują).
Zanim oddasz projekt:
- Przetestuj widok za pomocą klawiatury – czy z każdego pop-upu i menu da się wyjść klawiszem
Esc? - Upewnij się, że pierwszy element interaktywny posiada wpięty
skip-link. - Zbadaj dynamiczne przeładowania bloków (np. AJAX) upewniając się, że regiony z
aria-livekomunikują stan aplikacji.
W Design Solutions wdrażamy dostępność cyfrową już na etapie planowania architektury strony i pisania kodu w React/PHP, unikając technologicznego długu. Rozwiązujemy to natywnie, zachowując najwyższą wydajność zgodną z Core Web Vitals. Jeśli interesuje Cię, jakie podmioty ustawowo muszą spełniać te wymogi, sprawdź nasz dedykowany artykuł na temat obowiązku WCAG w Polsce.