Add ARIA attributes and focus management to cookie banner

Improve accessibility across the cookie banner components: add
aria-modal/aria-labelledby/aria-describedby on dialog cards, aria-label
on icon-only buttons and category toggles, aria-expanded on cookie
detail disclosure, aria-hidden on decorative SVGs, and auto-focus into
dialogs when they become visible.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-21 21:12:16 +04:00
parent 9a92ebd51a
commit 2b59a10c14
6 changed files with 27 additions and 11 deletions

View File

@@ -27,6 +27,9 @@ export class ProboBanner extends ProboElement {
private onStateChange = (e: Event): void => {
const { state } = (e as CustomEvent).detail;
this.hidden = state !== "banner";
if (state === "banner") {
this.focusFirst();
}
};
connectedCallback(): void {

View File

@@ -20,7 +20,16 @@ export interface ConsentDraft {
[category: string]: boolean;
}
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
export class ProboElement extends HTMLElement {
protected focusFirst(): void {
requestAnimationFrame(() => {
const el = this.querySelector<HTMLElement>(FOCUSABLE);
el?.focus();
});
}
protected findAncestor<T extends HTMLElement>(tagName: string): T | null {
let el: HTMLElement | null = this.parentElement;
while (el) {

View File

@@ -49,6 +49,7 @@ export class ProboCategoryToggle extends ProboElement {
if (!this.category || !this.root) return;
const name = this.category.categoryName;
this.checkbox.setAttribute("aria-label", name);
const isRequired = this.category.kind === "NECESSARY";
if (isRequired) {

View File

@@ -28,6 +28,7 @@ export class ProboPreferencePanel extends ProboElement {
this.hidden = state !== "panel";
if (state === "panel") {
(this.root as ProboCookieBannerRoot).resetDraft();
this.focusFirst();
}
};

View File

@@ -67,8 +67,8 @@ export class ProboSettingsButton extends HTMLElement {
.icon { display: flex; flex-shrink: 0; }
::slotted(*) { display: contents; }
</style>
<button part="button">
<span class="icon" part="icon">${COOKIE_ICON}</span>
<button part="button" aria-label="Cookie settings">
<span class="icon" part="icon" aria-hidden="true">${COOKIE_ICON}</span>
<slot></slot>
</button>
`;

View File

@@ -17,8 +17,8 @@ import type { ProboCookieBannerRoot } from "../components/cookie-banner-root";
import type { BannerConfig } from "../client";
import { THEMED_STYLES } from "./styles";
const CLOSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
const CHEVRON_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
const CLOSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
const CHEVRON_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>`;
export class ProboThemedBanner extends HTMLElement {
private shadow: ShadowRoot;
@@ -52,9 +52,9 @@ export class ProboThemedBanner extends HTMLElement {
<probo-cookie-banner-root banner-id="${this.esc(bannerId)}" base-url="${this.esc(baseUrl)}"${reopenAttr}>
<probo-banner>
<div class="floating" data-position="${this.esc(position)}">
<div class="card" role="dialog" aria-label="Cookie consent">
<p class="title">Cookie Preferences</p>
<p class="description" data-description>
<div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-banner-title" aria-describedby="probo-banner-desc">
<p class="title" id="probo-banner-title">Cookie Preferences</p>
<p class="description" id="probo-banner-desc" data-description>
We use cookies to improve your experience and analyze site traffic.
</p>
<div class="buttons">
@@ -68,16 +68,16 @@ export class ProboThemedBanner extends HTMLElement {
<probo-preference-panel>
<div class="floating" data-position="${this.esc(position)}">
<div class="card" role="dialog" aria-label="Cookie preferences">
<div class="card" role="dialog" aria-modal="true" aria-labelledby="probo-panel-title">
<div class="panel-header">
<p class="title" style="margin:0">Preferences</p>
<p class="title" id="probo-panel-title" style="margin:0">Preferences</p>
<button class="panel-close" data-action="back" aria-label="Close">
${CLOSE_ICON}
</button>
</div>
<probo-category-list>
<template>
<button class="cookie-toggle" data-action="toggle-cookies">
<button class="cookie-toggle" data-action="toggle-cookies" aria-expanded="false" aria-label="Show cookie details">
${CHEVRON_DOWN}
</button>
<div class="category-header">
@@ -141,6 +141,8 @@ export class ProboThemedBanner extends HTMLElement {
cookieList.setAttribute("hidden", "");
btn.classList.remove("open");
}
btn.setAttribute("aria-expanded", String(open));
btn.setAttribute("aria-label", open ? "Hide cookie details" : "Show cookie details");
});
}
@@ -150,7 +152,7 @@ export class ProboThemedBanner extends HTMLElement {
let html = "We use cookies to improve your experience and analyze site traffic.";
if (config.privacy_policy_url) {
html += ` <a href="${this.esc(config.privacy_policy_url)}" target="_blank" rel="noopener">Privacy Policy</a>`;
html += ` <a href="${this.esc(config.privacy_policy_url)}" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`;
}
el.innerHTML = html;
}