WCAG 2.1 AA: Focus-Styles, ARIA-Attribute, Tastatur-Navigation, Formvalidierung

- Default :focus-visible auf allen interaktiven Elementen (WCAG 2.4.7)
- A11y-Panel: role=group, Esc/Pfeiltasten, Fokus-Management
- Checkbox sr-only statt display:none (Screenreader-zugaenglich)
- Toggle-Switch Focus-Indikator
- aria-required, aria-expanded, aria-haspopup auf Formularen/Dropdowns
- Export-Dropdown: role=menu/menuitem/separator
- Sidebar: aria-expanded auf Sektionen, aria-hidden auf Chevrons
- Globaler Esc-Handler mit korrekter Schliess-Reihenfolge
- Formvalidierung: aria-invalid, aria-describedby, Fokus auf Fehler
- Notification-Items: role=button, tabindex=0
- Badges: aria-label/aria-hidden fuer Screenreader
- SR-Announcement bei Sidebar-Lage-Auswahl

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-05 12:53:13 +01:00
Ursprung a6c24366a0
Commit 559ace2f02
5 geänderte Dateien mit 188 neuen und 43 gelöschten Zeilen

Datei anzeigen

@@ -2539,6 +2539,11 @@ a:hover {
transition: transform 0.2s, background 0.2s;
}
.toggle-label input:focus-visible + .toggle-switch {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.toggle-label input:checked + .toggle-switch {
background: var(--accent);
}
@@ -3786,6 +3791,21 @@ a:hover {
top: 0;
}
/* === Default Focus-Visible fuer alle interaktiven Elemente (WCAG 2.4.7) === */
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, textarea:focus-visible,
[tabindex]:focus-visible, [role="button"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Form-Fehler (Accessibility) */
.form-error {
font-size: 12px;
color: var(--error);
margin-top: var(--sp-xs);
}
/* prefers-reduced-motion: alle Animationen deaktivieren */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
@@ -3847,10 +3867,22 @@ a:hover {
color: var(--text-primary);
user-select: none;
}
.a11y-option input[type="checkbox"] { display: none; }
.a11y-option input[type="checkbox"] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.a11y-option .toggle-switch {
flex-shrink: 0;
}
.a11y-option input:focus-visible + .toggle-switch {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.a11y-option input:checked + .toggle-switch {
background: var(--accent);
}
@@ -4021,16 +4053,17 @@ a:hover {
--input-border: #94A3B8;
}
/* === A11y: Focus-Hervorhebung === */
[data-a11y-focus="true"] a:focus,
[data-a11y-focus="true"] button:focus,
[data-a11y-focus="true"] input:focus,
[data-a11y-focus="true"] select:focus,
[data-a11y-focus="true"] textarea:focus,
[data-a11y-focus="true"] [tabindex]:focus,
[data-a11y-focus="true"] [role="button"]:focus {
/* === A11y: Verstaerkte Focus-Anzeige === */
[data-a11y-focus="true"] a:focus-visible,
[data-a11y-focus="true"] button:focus-visible,
[data-a11y-focus="true"] input:focus-visible,
[data-a11y-focus="true"] select:focus-visible,
[data-a11y-focus="true"] textarea:focus-visible,
[data-a11y-focus="true"] [tabindex]:focus-visible,
[data-a11y-focus="true"] [role="button"]:focus-visible {
outline: 3px solid var(--accent) !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 4px rgba(200, 168, 81, 0.3) !important;
}
/* === A11y: Größere Schrift === */

Datei anzeigen

@@ -53,8 +53,8 @@
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-incidents')" role="button" tabindex="0">
<span class="sidebar-chevron" id="chevron-active-incidents">&#9662;</span>
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-incidents')" role="button" tabindex="0" aria-expanded="true">
<span class="sidebar-chevron" id="chevron-active-incidents" aria-hidden="true">&#9662;</span>
Aktive Lagen
<span class="sidebar-section-count" id="count-active-incidents"></span>
</h2>
@@ -62,8 +62,8 @@
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-research')" role="button" tabindex="0">
<span class="sidebar-chevron" id="chevron-active-research">&#9662;</span>
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-research')" role="button" tabindex="0" aria-expanded="true">
<span class="sidebar-chevron" id="chevron-active-research" aria-hidden="true">&#9662;</span>
Aktive Recherchen
<span class="sidebar-section-count" id="count-active-research"></span>
</h2>
@@ -71,8 +71,8 @@
</div>
<div class="sidebar-section">
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('archived-incidents')" role="button" tabindex="0">
<span class="sidebar-chevron" id="chevron-archived-incidents">&#9662;</span>
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('archived-incidents')" role="button" tabindex="0" aria-expanded="false">
<span class="sidebar-chevron" id="chevron-archived-incidents" aria-hidden="true">&#9662;</span>
Archiv
<span class="sidebar-section-count" id="count-archived-incidents"></span>
</h2>
@@ -111,15 +111,15 @@
<button class="btn btn-primary btn-small" id="refresh-btn">Aktualisieren</button>
<button class="btn btn-secondary btn-small" id="edit-incident-btn">Bearbeiten</button>
<div class="export-dropdown" id="export-dropdown">
<button class="btn btn-secondary btn-small" onclick="App.toggleExportDropdown(event)">Exportieren &#9662;</button>
<div class="export-dropdown-menu" id="export-dropdown-menu">
<button class="export-dropdown-item" onclick="App.exportIncident('md','report')">Lagebericht (Markdown)</button>
<button class="export-dropdown-item" onclick="App.exportIncident('json','report')">Lagebericht (JSON)</button>
<hr class="export-dropdown-divider">
<button class="export-dropdown-item" onclick="App.exportIncident('md','full')">Vollexport (Markdown)</button>
<button class="export-dropdown-item" onclick="App.exportIncident('json','full')">Vollexport (JSON)</button>
<hr class="export-dropdown-divider">
<button class="export-dropdown-item" onclick="App.printIncident()">Drucken / PDF</button>
<button class="btn btn-secondary btn-small" onclick="App.toggleExportDropdown(event)" aria-expanded="false" aria-haspopup="true">Exportieren &#9662;</button>
<div class="export-dropdown-menu" id="export-dropdown-menu" role="menu">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','report')">Lagebericht (Markdown)</button>
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','report')">Lagebericht (JSON)</button>
<hr class="export-dropdown-divider" role="separator">
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('md','full')">Vollexport (Markdown)</button>
<button class="export-dropdown-item" role="menuitem" onclick="App.exportIncident('json','full')">Vollexport (JSON)</button>
<hr class="export-dropdown-divider" role="separator">
<button class="export-dropdown-item" role="menuitem" onclick="App.printIncident()">Drucken / PDF</button>
</div>
</div>
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
@@ -193,7 +193,7 @@
<div class="grid-stack-item-content">
<div class="card incident-analysis-summary">
<div class="card-header">
<div class="card-title clickable" onclick="openContentModal('Lagebild', 'summary-content')">Lagebild</div>
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Lagebild', 'summary-content')">Lagebild</div>
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
</div>
<div id="summary-content">
@@ -207,7 +207,7 @@
<div class="grid-stack-item-content">
<div class="card incident-analysis-factcheck" id="factcheck-card">
<div class="card-header">
<div class="card-title clickable" onclick="openContentModal('Faktencheck', 'factcheck-list')">Faktencheck</div>
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Faktencheck', 'factcheck-list')">Faktencheck</div>
<div class="fc-filter-bar" id="fc-filters"></div>
</div>
<div class="factcheck-list" id="factcheck-list">
@@ -236,7 +236,7 @@
<div class="grid-stack-item-content">
<div class="card timeline-card">
<div class="card-header">
<div class="card-title clickable" onclick="openContentModal('Ereignis-Timeline', 'timeline')">Ereignis-Timeline</div>
<div class="card-title clickable" role="button" tabindex="0" onclick="openContentModal('Ereignis-Timeline', 'timeline')">Ereignis-Timeline</div>
<div class="ht-controls">
<div class="ht-filter-group">
<button class="ht-filter-btn active" data-filter="all" onclick="App.setTimelineFilter('all')" aria-pressed="true">Alle</button>
@@ -293,7 +293,7 @@
<div class="modal-body">
<div class="form-group">
<label for="inc-title">Titel des Vorfalls</label>
<input type="text" id="inc-title" required placeholder="z.B. Explosion in Madrid">
<input type="text" id="inc-title" required aria-required="true" placeholder="z.B. Explosion in Madrid">
</div>
<div class="form-group">
<label for="inc-description">Beschreibung / Kontext</label>
@@ -544,7 +544,7 @@
</div>
<div class="form-group">
<label for="fb-message">Nachricht</label>
<textarea id="fb-message" required minlength="10" maxlength="5000" rows="6" placeholder="Beschreibe dein Anliegen (mind. 10 Zeichen)..."></textarea>
<textarea id="fb-message" required aria-required="true" minlength="10" maxlength="5000" rows="6" placeholder="Beschreibe dein Anliegen (mind. 10 Zeichen)..."></textarea>
<div class="form-hint"><span id="fb-char-count">0</span> / 5.000 Zeichen</div>
</div>
</div>

Datei anzeigen

@@ -30,7 +30,7 @@
<form id="email-form">
<div class="form-group">
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" autocomplete="email" required placeholder="name@organisation.de">
<input type="email" id="email" name="email" autocomplete="email" required aria-required="true" placeholder="name@organisation.de">
</div>
<button type="submit" class="btn btn-primary btn-full" id="email-btn">Anmelden</button>
</form>
@@ -42,7 +42,7 @@
</p>
<div class="form-group">
<label for="code">Code eingeben</label>
<input type="text" id="code" name="code" autocomplete="one-time-code" required
<input type="text" id="code" name="code" autocomplete="one-time-code" required aria-required="true"
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
style="text-align:center; font-size:24px; letter-spacing:8px; font-family:monospace;">
</div>

Datei anzeigen

@@ -59,7 +59,7 @@ const A11yManager = {
<path d="M12 8c-3.3 0-6 .5-6 .5v2s2.7-.5 5-.5v3l-3 7h2.5l2.5-5.5 2.5 5.5h2.5l-3-7v-3c2.3 0 5 .5 5 .5v-2S15.3 8 12 8z"/>
</svg>
</button>
<div class="a11y-panel" id="a11y-panel" style="display:none;">
<div class="a11y-panel" id="a11y-panel" role="group" aria-label="Barrierefreiheits-Einstellungen" style="display:none;">
<div class="a11y-panel-title">Barrierefreiheit</div>
<label class="a11y-option">
<input type="checkbox" id="a11y-contrast">
@@ -69,7 +69,7 @@ const A11yManager = {
<label class="a11y-option">
<input type="checkbox" id="a11y-focus">
<span class="toggle-switch"></span>
<span>Focus-Hervorhebung</span>
<span>Verstärkte Focus-Anzeige</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-fontsize">
@@ -108,6 +108,28 @@ const A11yManager = {
}
});
// Keyboard: Esc schließt, Pfeiltasten navigieren
container.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this._isOpen) {
e.stopPropagation();
this._closePanel();
return;
}
if (!this._isOpen) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
const idx = options.indexOf(document.activeElement);
let next;
if (e.key === 'ArrowDown') {
next = idx < options.length - 1 ? idx + 1 : 0;
} else {
next = idx > 0 ? idx - 1 : options.length - 1;
}
options[next].focus();
}
});
// Einstellungen anwenden + Checkboxen synchronisieren
this._apply();
this._syncUI();
@@ -146,12 +168,19 @@ const A11yManager = {
this._isOpen = true;
document.getElementById('a11y-panel').style.display = '';
document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
// Fokus auf erste Option setzen
requestAnimationFrame(() => {
const first = document.querySelector('.a11y-option input[type="checkbox"]');
if (first) first.focus();
});
},
_closePanel() {
this._isOpen = false;
document.getElementById('a11y-panel').style.display = 'none';
document.getElementById('a11y-btn').setAttribute('aria-expanded', 'false');
const btn = document.getElementById('a11y-btn');
btn.setAttribute('aria-expanded', 'false');
btn.focus();
}
};
@@ -291,7 +320,7 @@ const NotificationCenter = {
const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const unreadClass = n.read ? '' : ' unread';
const icon = n.icon || 'info';
return `<div class="notification-item${unreadClass}" onclick="NotificationCenter._handleClick(${n.incident_id})" data-id="${n.incident_id}">
return `<div class="notification-item${unreadClass}" onclick="NotificationCenter._handleClick(${n.incident_id})" data-id="${n.incident_id}" role="button" tabindex="0">
<div class="notification-item-icon ${icon}">${this._iconSymbol(icon)}</div>
<div class="notification-item-body">
<div class="notification-item-title">${this._escapeHtml(n.title)}</div>
@@ -566,10 +595,27 @@ const App = {
this.renderSidebar();
},
_announceForSR(text) {
let el = document.getElementById('sr-announcement');
if (!el) {
el = document.createElement('div');
el.id = 'sr-announcement';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.className = 'sr-only';
document.body.appendChild(el);
}
el.textContent = '';
requestAnimationFrame(() => { el.textContent = text; });
},
async selectIncident(id) {
this.closeRefreshHistory();
this.currentIncidentId = id;
localStorage.setItem('selectedIncidentId', id);
// SR-Announcement
const inc = this.incidents.find(i => i.id === id);
if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title);
this.renderSidebar();
document.getElementById('empty-state').style.display = 'none';
@@ -1352,9 +1398,40 @@ const App = {
};
},
_clearFormErrors(formEl) {
formEl.querySelectorAll('.form-error').forEach(el => el.remove());
formEl.querySelectorAll('[aria-invalid]').forEach(el => {
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
});
},
_showFieldError(field, message) {
field.setAttribute('aria-invalid', 'true');
const errorId = field.id + '-error';
field.setAttribute('aria-describedby', errorId);
const errorEl = document.createElement('div');
errorEl.className = 'form-error';
errorEl.id = errorId;
errorEl.setAttribute('role', 'alert');
errorEl.textContent = message;
field.parentNode.appendChild(errorEl);
},
async handleFormSubmit(e) {
e.preventDefault();
const submitBtn = document.getElementById('modal-new-submit');
const form = document.getElementById('new-incident-form');
this._clearFormErrors(form);
// Validierung
const titleField = document.getElementById('inc-title');
if (!titleField.value.trim()) {
this._showFieldError(titleField, 'Bitte einen Titel eingeben.');
titleField.focus();
return;
}
submitBtn.disabled = true;
try {
@@ -1926,12 +2003,18 @@ const App = {
event.stopPropagation();
const menu = document.getElementById('export-dropdown-menu');
if (!menu) return;
menu.classList.toggle('show');
const isOpen = menu.classList.toggle('show');
const btn = menu.previousElementSibling;
if (btn) btn.setAttribute('aria-expanded', String(isOpen));
},
_closeExportDropdown() {
const menu = document.getElementById('export-dropdown-menu');
if (menu) menu.classList.remove('show');
if (menu) {
menu.classList.remove('show');
const btn = menu.previousElementSibling;
if (btn) btn.setAttribute('aria-expanded', 'false');
}
},
async exportIncident(format, scope) {
@@ -2013,12 +2096,17 @@ const App = {
async submitFeedback(e) {
e.preventDefault();
const form = document.getElementById('feedback-form');
this._clearFormErrors(form);
const btn = document.getElementById('fb-submit-btn');
const category = document.getElementById('fb-category').value;
const message = document.getElementById('fb-message').value.trim();
const msgField = document.getElementById('fb-message');
const message = msgField.value.trim();
if (message.length < 10) {
UI.showToast('Bitte mindestens 10 Zeichen eingeben.', 'warning');
this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.');
msgField.focus();
return;
}
@@ -2047,6 +2135,9 @@ const App = {
if (chevron) {
chevron.classList.toggle('open', isHidden);
}
// aria-expanded auf dem Section-Title synchronisieren
const title = chevron ? chevron.closest('.sidebar-section-title') : null;
if (title) title.setAttribute('aria-expanded', String(isHidden));
},
// === Quellenverwaltung ===
@@ -2963,6 +3054,27 @@ document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Schließ-Reihenfolge: A11y-Panel > Notification-Panel > Export-Dropdown > FC-Dropdown > Modals
if (A11yManager._isOpen) {
A11yManager._closePanel();
return;
}
if (NotificationCenter._isOpen) {
NotificationCenter.close();
return;
}
const exportMenu = document.getElementById('export-dropdown-menu');
if (exportMenu && exportMenu.classList.contains('show')) {
App._closeExportDropdown();
return;
}
const fcMenu = document.querySelector('.fc-dropdown-menu.open');
if (fcMenu) {
fcMenu.classList.remove('open');
const fcBtn = fcMenu.previousElementSibling;
if (fcBtn) fcBtn.setAttribute('aria-expanded', 'false');
return;
}
document.querySelectorAll('.modal-overlay.active').forEach(m => {
closeModal(m.id);
});

Datei anzeigen

@@ -26,13 +26,13 @@ const UI = {
return `
<div class="incident-item ${activeClass}" data-id="${incident.id}" onclick="App.selectIncident(${incident.id})" role="button" tabindex="0">
<span class="incident-dot ${dotClass}" id="dot-${incident.id}"></span>
<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>
<div style="flex:1;min-width:0;">
<div class="incident-name">${this.escape(incident.title)}</div>
<div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}</div>
</div>
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;">PRIVAT</span>' : ''}
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" title="Auto-Refresh aktiv">&#x21bb;</span>' : ''}
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" role="img" aria-label="Auto-Refresh aktiv">&#x21bb;</span>' : ''}
</div>
`;
},
@@ -106,7 +106,7 @@ const UI = {
</label>`;
}).join('');
return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)">Filter</button>
return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)" aria-haspopup="true" aria-expanded="false">Filter</button>
<div class="fc-dropdown-menu" id="fc-dropdown-menu">${items}</div>`;
},