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

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