Skip to content

Feat: add a11y implementation#55

Open
YvetteNikolov wants to merge 27 commits intofabiodalez-dev:mainfrom
YvetteNikolov:feat/wcag-compliance
Open

Feat: add a11y implementation#55
YvetteNikolov wants to merge 27 commits intofabiodalez-dev:mainfrom
YvetteNikolov:feat/wcag-compliance

Conversation

@YvetteNikolov
Copy link
Copy Markdown

@YvetteNikolov YvetteNikolov commented Apr 2, 2026

Hello!

I am also a developer at Yard (https://yard.nl/). We manage a big number of municipality websites that undergo mandatory WCAG 2.2 audits. Until now we used CookieYes and maintained a separate npm package that includes accessibility fixes gathered from those audit reports.

We would like to migrate to your plugin. We'd need these improvements, hence this PR. What's changed:

  • a11y.js (big) - new file that applies all ARIA improvements at runtime after fazcookie_banner_loaded
  • script.js (small) - focus loop now applies to box-type banners, not just popup.
  • template.json (small) - minor CSS normalisation: heading margin-bottom and font-size
  • Added some Playwright tests. Had to add WP_LOGIN_PATH .env variable because our Bedrock setup prepends /wp/ to the login URL

Initially, the structural fixes were applied by a dedicated PHP class at template-build time (9b66da4), baked into the cached template in the options table. I later moved everything into a11y.js (26a6ab1) so all accessibility logic lives in one place and is centrally maintained.

Summary by CodeRabbit

Release Notes

  • New Features

    • Accessibilità migliorata per il banner dei cookie con supporto completo ARIA, etichette dinamiche e navigazione da tastiera ottimizzata.
  • Tests

    • Nuova suite di test automatici end-to-end per validare struttura DOM, conformità ARIA e comportamento da tastiera del banner.

… banner-type assumption

Remove the always-true `if (bannerType !== "classic")` wrapper that was
left behind after the early-return guard above it. The two lines of
banner-focus-loop setup now run unconditionally for all non-classic types.

Add a beforeAll in the focus-loop describe block that explicitly sets
faz_banner_type to 'box' via setOption, making the test type-specific
and proving the fix covers the previously excluded box banner type.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

Walkthrough

I cambiamenti introducono un sistema completo di accessibilità (a11y) per il banner dei cookie, aggiungendo un nuovo modulo JavaScript che implementa miglioramenti ARIA, gestione della tastiera (Escape, focus loop), e sincronizzazione dinamica degli attributi semantici, insieme a test end-to-end dedicati.

Changes

Cohort / File(s) Summary
A11y Module Implementation
frontend/class-frontend.php, frontend/js/a11y.js
Nuovo modulo JavaScript di accessibilità enqueued dal PHP con configurazione localizzata. Modifica il DOM del banner aggiungendo heading stabili (<h2>), wrapping accordion in <h3>, assegnando role="switch" ai toggle, impostando role="dialog" e aria-labelledby corretti, gestendo chiusura con tasto Escape, e sincronizzando aria-label dinamici per gli stati dei checkbox.
Focus Loop Enhancement
frontend/js/script.js
Aggiorna _fazLoopFocus() per applicare la gestione del focus loop a tutti i banner type eccetto "classic", non solo "popup", calcolando sempre gli elementi focusable via _fazGetFocusableElements("notice").
Testing Infrastructure
tests/e2e/global-setup.ts, tests/e2e/specs/a11y.spec.ts
Aggiunge supporto per variabile d'ambiente opzionale WP_LOGIN_PATH nel setup globale. Introduce nuova suite di test e2e che valida struttura DOM, attributi ARIA, gestione della tastiera (Tab, Escape), sincronizzazione stati, e comportamenti di accessibilità del banner e modal.

Sequence Diagram(s)

sequenceDiagram
    participant Event as Banner Load Event
    participant A11y as A11y Module
    participant DOM as DOM Elements
    participant Keyboard as Keyboard Handler
    
    Event->>A11y: fazcookie_banner_loaded
    A11y->>DOM: Restructure headings & IDs
    A11y->>DOM: Add ARIA roles & attributes
    A11y->>DOM: Wrap accordion in heading tags
    A11y->>Keyboard: Attach Escape key listeners
    A11y->>Keyboard: Setup focus loop handlers
    A11y->>DOM: Sync aria-labels on checkbox state
    Keyboard->>DOM: Close banner/modal on Escape/Tab
    DOM->>DOM: Update aria-label on state change
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minuti

Poem

🐰 Ecco il banner che salta più in alto,
Con ARIA roles e un focus ben fatto,
Escape per chiudere, Tab ben guidato,
L'accessibilità è stato completato!
Un test che controlla ogni dettaglio,
Il coniglio approva questo bel travaglio! 🎉

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 82.35% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed Il titolo descrive accuratamente il principale cambiamento: l'implementazione di una soluzione di accessibilità nativa (a11y) nel progetto.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@YvetteNikolov YvetteNikolov changed the title docs: add native a11y implementation design spec Feat: add a11y implementation Apr 2, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/js/a11y.js`:
- Around line 131-135: The addDescriptionWrapperId function (and the similar
blocks at the other ranges) only targets detail-* / show-desc-button /
hide-desc-button selectors, so CCPA/opt-out elements (optout-description,
optout-close, optout-cancel-button, optout-show-desc-button,
optout-hide-desc-button) are omitted; update addDescriptionWrapperId and the
other related routines in frontend/js/a11y.js to also query and handle elements
with the optout-* IDs/classes, set the same id/aria-controls values (e.g.,
ensure wrapper id 'faz-desc-content' is applied to optout-description too), and
include the optout-close/optout-cancel-button in the Escape-key close handler so
aria-controls and Escape-based closing behavior mirror the existing detail-*
flow.

In `@tests/e2e/global-setup.ts`:
- Around line 13-21: The preflight uses WP_LOGIN_PATH via the local variable
loginPath but other harness code still hardcodes '/wp-login.php', causing
misleading errors; create a small shared utility (e.g., getWpLoginPath or
wpLoginPath constant) used everywhere instead of the literal string, update the
fixtures that call loginAsAdmin to import and use that utility, and change the
thrown Error message that currently interpolates '/wp-login.php' to use the
loginPath variable so diagnostics reflect the actual path.

In `@tests/e2e/specs/a11y.spec.ts`:
- Around line 151-152: L'asserzione usa token inglesi fissi e fallisce con le
traduzioni; invece di expect(label).toMatch(/enabled|disabled/i) recupera
l'atteso localizzato da fazA11yConfig (le stringhe in
frontend/class-frontend.php) oppure verifica il nome della categoria più il
cambiamento di stato (es. leggi il label prima dell'azione, esegui il toggle su
checkbox.getAttribute('aria-label' o textContent) e confronta che lo stato è
invertito), aggiornando il test per usare i valori localizzati o la logica di
confronto stato/categoria invece di "enabled|disabled".
- Around line 175-181: The test dependency on fixture copy causes flakiness:
update the a11y.spec.ts test that checks the show-desc-button (selector
'[data-faz-tag="show-desc-button"]') so it no longer assumes the control always
exists; either (A) ensure the test setup injects a deliberately long banner
fixture so _fazSetShowMoreLess() will create the control, or (B) make the
assertion conditional by first checking for the presence/visibility of the
show-desc-button and only asserting its aria-controls when it exists (and
otherwise assert that the banner is short/unchanged). Modify the test setup or
the assertion flow around the show-desc-button locator accordingly.
- Around line 69-78: The test uses only button elements while the runtime
_fazLoopFocus() targets the first/last focusable among links, buttons and
elements with tabindex, so change the test to collect focusable elements the
same way _fazLoopFocus() does (use notice.locator with the same selector used in
script.js), then focus the last item from that collection and assert Tab cycles
to the first element; update references to
buttons.first()/buttons.last()/buttons accordingly so the test mirrors the
runtime behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 065a4b73-ef58-491e-9686-ebf1eee273d1

📥 Commits

Reviewing files that changed from the base of the PR and between 513a901 and d22cfa1.

📒 Files selected for processing (7)
  • admin/modules/banners/includes/templates/6.0.0/template.json
  • admin/modules/banners/includes/templates/6.2.0/template.json
  • frontend/class-frontend.php
  • frontend/js/a11y.js
  • frontend/js/script.js
  • tests/e2e/global-setup.ts
  • tests/e2e/specs/a11y.spec.ts

Comment on lines +131 to +135
function addDescriptionWrapperId() {
var wrapper = document.querySelector( '[data-faz-tag="detail-description"]' );
if ( ! wrapper ) return;
wrapper.setAttribute( 'id', 'faz-desc-content' );
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Il popup CCPA/opt-out resta fuori da queste correzioni runtime.

Qui usi solo i selettori detail-* / show-desc-button / hide-desc-button, ma frontend/js/script.js e gli shortcode preparati in frontend/class-frontend.php gestiscono anche optout-description, optout-close, optout-cancel-button, optout-show-desc-button e optout-hide-desc-button. Nel flusso CCPA non verranno quindi applicati né aria-controls né la chiusura con Escape.

💡 Possibile correzione
-        var wrapper = document.querySelector( '[data-faz-tag="detail-description"]' );
+        var wrapper = document.querySelector(
+            '[data-faz-tag="detail-description"], [data-faz-tag="optout-description"]'
+        );
         if ( ! wrapper ) return;
         wrapper.setAttribute( 'id', 'faz-desc-content' );
@@
-                var closeBtn = modal.querySelector( '[data-faz-tag="detail-close"]' );
+                var closeBtn = modal.querySelector(
+                    '[data-faz-tag="detail-close"], [data-faz-tag="optout-close"], [data-faz-tag="optout-cancel-button"]'
+                );
                 if ( closeBtn ) {
                     closeBtn.click();
                 }
@@
-            var btn = wrapper.querySelector( '[data-faz-tag="show-desc-button"], [data-faz-tag="hide-desc-button"]' );
+            var btn = wrapper.querySelector(
+                '[data-faz-tag="show-desc-button"], [data-faz-tag="hide-desc-button"], [data-faz-tag="optout-show-desc-button"], [data-faz-tag="optout-hide-desc-button"]'
+            );
             if ( btn ) btn.setAttribute( 'aria-controls', 'faz-desc-content' );

Also applies to: 177-186, 231-245

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/js/a11y.js` around lines 131 - 135, The addDescriptionWrapperId
function (and the similar blocks at the other ranges) only targets detail-* /
show-desc-button / hide-desc-button selectors, so CCPA/opt-out elements
(optout-description, optout-close, optout-cancel-button,
optout-show-desc-button, optout-hide-desc-button) are omitted; update
addDescriptionWrapperId and the other related routines in frontend/js/a11y.js to
also query and handle elements with the optout-* IDs/classes, set the same
id/aria-controls values (e.g., ensure wrapper id 'faz-desc-content' is applied
to optout-description too), and include the optout-close/optout-cancel-button in
the Escape-key close handler so aria-controls and Escape-based closing behavior
mirror the existing detail-* flow.

Comment on lines +13 to +21
const loginPath = process.env.WP_LOGIN_PATH ?? '/wp-login.php';
const loginPage = await api.get(loginPath);
if (!loginPage.ok()) {
await api.dispose();
throw new Error(`WordPress login page not reachable at ${baseURL}/wp-login.php (status ${loginPage.status()}).`);
}

// Verify credentials actually work before running the full suite.
const loginResponse = await api.post('/wp-login.php', {
const loginResponse = await api.post(loginPath, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

WP_LOGIN_PATH non è ancora propagato a tutto l'harness E2E.

Qui il preflight usa il nuovo env, ma tests/e2e/fixtures/wp-fixture.ts continua a navigare verso /wp-login.php e questo errore stampa ancora quel path hardcoded. Su Bedrock il setup può passare, ma gli spec che chiamano loginAsAdmin() continueranno a rompersi con diagnostica fuorviante. Porterei il path in una utility condivisa e userei loginPath anche nel messaggio di errore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/global-setup.ts` around lines 13 - 21, The preflight uses
WP_LOGIN_PATH via the local variable loginPath but other harness code still
hardcodes '/wp-login.php', causing misleading errors; create a small shared
utility (e.g., getWpLoginPath or wpLoginPath constant) used everywhere instead
of the literal string, update the fixtures that call loginAsAdmin to import and
use that utility, and change the thrown Error message that currently
interpolates '/wp-login.php' to use the loginPath variable so diagnostics
reflect the actual path.

Comment on lines +69 to +78
// Collect all visible, non-disabled focusable elements in the notice.
const buttons = notice.locator('button:not([disabled])');
await expect(buttons.first()).toBeVisible();

// Focus the last button in the banner.
await buttons.last().focus();

// Tab should loop back to the first button.
await page.keyboard.press('Tab');
await expect(buttons.first()).toBeFocused();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Il test del focus loop non usa lo stesso set di focusable del runtime.

_fazLoopFocus() in frontend/js/script.js si aggancia al primo/ultimo focusable tra link, button e [tabindex]; qui invece consideri solo i button. Se l'ultimo focusable reale del banner è un link o un elemento custom, questa asserzione fallisce o passa per il motivo sbagliato.

💡 Possibile correzione
-    const buttons = notice.locator('button:not([disabled])');
-    await expect(buttons.first()).toBeVisible();
+    const focusables = notice.locator(
+      'a:not([disabled]), button:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'
+    );
+    await expect(focusables.first()).toBeVisible();
@@
-    await buttons.last().focus();
+    await focusables.last().focus();
@@
-    await expect(buttons.first()).toBeFocused();
+    await expect(focusables.first()).toBeFocused();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/specs/a11y.spec.ts` around lines 69 - 78, The test uses only button
elements while the runtime _fazLoopFocus() targets the first/last focusable
among links, buttons and elements with tabindex, so change the test to collect
focusable elements the same way _fazLoopFocus() does (use notice.locator with
the same selector used in script.js), then focus the last item from that
collection and assert Tab cycles to the first element; update references to
buttons.first()/buttons.last()/buttons accordingly so the test mirrors the
runtime behavior.

Comment on lines +151 to +152
const label = await checkbox.getAttribute('aria-label');
expect(label).toMatch(/enabled|disabled/i);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Questa asserzione rompe appena le traduzioni sono attive.

Le stringhe arrivano da fazA11yConfig e sono localizzate in frontend/class-frontend.php, quindi /enabled|disabled/i funziona solo su installazioni in inglese. Qui conviene derivare l'atteso dalla config localizzata oppure verificare il nome categoria e il cambio di stato, non i token inglesi.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/specs/a11y.spec.ts` around lines 151 - 152, L'asserzione usa token
inglesi fissi e fallisce con le traduzioni; invece di
expect(label).toMatch(/enabled|disabled/i) recupera l'atteso localizzato da
fazA11yConfig (le stringhe in frontend/class-frontend.php) oppure verifica il
nome della categoria più il cambiamento di stato (es. leggi il label prima
dell'azione, esegui il toggle su checkbox.getAttribute('aria-label' o
textContent) e confronta che lo stato è invertito), aggiornando il test per
usare i valori localizzati o la logica di confronto stato/categoria invece di
"enabled|disabled".

Comment on lines +175 to +181
test('show-more button has aria-controls="faz-desc-content"', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-faz-tag="notice"]')).toBeVisible();
await page.locator('[data-faz-tag="settings-button"]').first().click();

const showMoreBtn = page.locator('[data-faz-tag="show-desc-button"]');
await expect(showMoreBtn).toHaveAttribute('aria-controls', 'faz-desc-content');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Il test su show-desc-button dipende dal copy del fixture, non dalla fix a11y.

_fazSetShowMoreLess() crea quel controllo solo quando la descrizione supera la soglia ed è divisa in più paragrafi. Se il banner di test cambia lunghezza o markup, la suite fallirà anche con a11y.js corretto. Meglio fissare esplicitamente un banner lungo nel setup o rendere l'asserzione condizionata alla presenza del controllo.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/specs/a11y.spec.ts` around lines 175 - 181, The test dependency on
fixture copy causes flakiness: update the a11y.spec.ts test that checks the
show-desc-button (selector '[data-faz-tag="show-desc-button"]') so it no longer
assumes the control always exists; either (A) ensure the test setup injects a
deliberately long banner fixture so _fazSetShowMoreLess() will create the
control, or (B) make the assertion conditional by first checking for the
presence/visibility of the show-desc-button and only asserting its aria-controls
when it exists (and otherwise assert that the banner is short/unchanged). Modify
the test setup or the assertion flow around the show-desc-button locator
accordingly.

@fabiodalez-dev
Copy link
Copy Markdown
Owner

Hey @YvetteNikolov, great work on this! The design spec and implementation plan are really thorough, and the a11y.js approach is clean — having all ARIA improvements in one dedicated file makes maintenance much easier.

A few notes on overlap with PR #53 (which I'm about to merge):

Already implemented in #53

These fixes are already in my branch and will be in main after merge:

  • Focus trap for box-type banners — I removed the if (classic) return early exit in _fazLoopFocus(), so all banner types now get focus trapping. Your change (if (popup)if (!classic)) achieves the same thing slightly differently.
  • Escape key on preference center — I added a keydown listener that closes the preference center on Escape.
  • Age gate accessibilityrole="dialog", aria-modal="true", aria-labelledby, and focus trap on the age gate modal.

Not in #53 — unique value from your PR

These are improvements we don't have and would love to integrate:

  • a11y.js runtime ARIA — banner role="dialog" override, aria-labelledby on both banner and modal, dynamic checkbox labels with state feedback
  • role="switch" on category checkboxes — proper semantics
  • Heading hierarchy (<h2> for titles, <h3> wrapping accordion buttons) — we didn't touch heading semantics
  • aria-controls on show/hide description button with MutationObserver
  • Escape key on the banner itself (not just the preference center)
  • E2E accessibility tests (a11y.spec.ts) — we have no dedicated a11y test coverage

Suggested next step

I'm merging #53 now. Could you rebase your branch on the updated main and:

  1. Remove the _fazLoopFocus change in script.js (already applied)
  2. Remove the Escape-on-preference-center part from a11y.js (already handled in script.js)
  3. Keep everything else — the a11y.js approach is better than our inline fixes for the ARIA work

Happy to help with the rebase if you need it. Really appreciate the contribution from Yard — this is exactly the WCAG work Wybe mentioned was coming!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants