feat(a11y): WCAG 2.2 accessibility fixes across four areas
- modal/_validateField: set aria-invalid on invalid inputs so screen readers announce field errors; login.js mirrors this for username/password fields - color pickers (notes, calendar): wrap swatches in role="radiogroup" with aria-labelledby, add aria-checked per swatch, localized aria-labels instead of hex values, roving tabindex with Arrow/Enter/Space keyboard navigation - nav badges: badge spans get aria-hidden="true"; nav link aria-label updated to include overdue count (tasks) or pending reminder count (reminders) - router: remove aria-live from <main> (caused full page re-reads on nav); add dedicated #route-announcer sr-only region with aria-live=polite + aria-atomic, announces page label 50ms after render completes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -591,6 +591,7 @@ function _validateField(input) {
|
||||
const hasValue = input.value.trim().length > 0;
|
||||
group?.classList.toggle('form-field--error', !hasValue);
|
||||
group?.classList.toggle('form-field--valid', hasValue);
|
||||
input.setAttribute('aria-invalid', String(!hasValue));
|
||||
return hasValue;
|
||||
}
|
||||
|
||||
|
||||
+22
-3
@@ -170,7 +170,8 @@
|
||||
"filterGroupStatus": "Status",
|
||||
"filterGroupPriority": "Priorität",
|
||||
"filterGroupPerson": "Person",
|
||||
"filterClearAll": "Alle Filter zurücksetzen"
|
||||
"filterClearAll": "Alle Filter zurücksetzen",
|
||||
"navLabelOverdue": "Aufgaben, {{count}} überfällig"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "Einkauf",
|
||||
@@ -299,7 +300,17 @@
|
||||
"locationPlaceholder": "Optional",
|
||||
"assignedLabel": "Zugewiesen an",
|
||||
"assignedNobody": "- Niemand -",
|
||||
"colorLabel": "Farbe {{color}}",
|
||||
"colorLabel": "Farbe",
|
||||
"colorBlue": "Blau",
|
||||
"colorGreen": "Grün",
|
||||
"colorOrange": "Orange",
|
||||
"colorRed": "Rot",
|
||||
"colorPurple": "Lila",
|
||||
"colorCoral": "Korall",
|
||||
"colorSkyBlue": "Hellblau",
|
||||
"colorYellow": "Gelb",
|
||||
"colorGray": "Grau",
|
||||
"colorCyan": "Cyan",
|
||||
"descriptionLabel": "Beschreibung",
|
||||
"descriptionPlaceholder": "Optional…",
|
||||
"popupEdit": "Bearbeiten",
|
||||
@@ -379,7 +390,15 @@
|
||||
"formatLink": "Link",
|
||||
"formatCode": "Code",
|
||||
"formatQuote": "Zitat",
|
||||
"formatDivider": "Trennlinie"
|
||||
"formatDivider": "Trennlinie",
|
||||
"colorYellow": "Gelb",
|
||||
"colorAmber": "Hellgelb",
|
||||
"colorGreen": "Grün",
|
||||
"colorTeal": "Türkis",
|
||||
"colorBlue": "Blau",
|
||||
"colorPurple": "Lila",
|
||||
"colorOrange": "Orange",
|
||||
"colorWhite": "Weiß"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Kontakte",
|
||||
|
||||
+48
-11
@@ -46,6 +46,19 @@ const EVENT_COLORS = [
|
||||
'#8E8E93', '#30B0C7',
|
||||
];
|
||||
|
||||
const EVENT_COLOR_NAMES = () => ({
|
||||
'#007AFF': t('calendar.colorBlue'),
|
||||
'#34C759': t('calendar.colorGreen'),
|
||||
'#FF9500': t('calendar.colorOrange'),
|
||||
'#FF3B30': t('calendar.colorRed'),
|
||||
'#AF52DE': t('calendar.colorPurple'),
|
||||
'#FF6B35': t('calendar.colorCoral'),
|
||||
'#5AC8FA': t('calendar.colorSkyBlue'),
|
||||
'#FFCC00': t('calendar.colorYellow'),
|
||||
'#8E8E93': t('calendar.colorGray'),
|
||||
'#30B0C7': t('calendar.colorCyan'),
|
||||
});
|
||||
|
||||
const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
|
||||
|
||||
/**
|
||||
@@ -843,15 +856,36 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
||||
|
||||
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
|
||||
|
||||
// Farb-Auswahl
|
||||
panel.querySelectorAll('.color-swatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => {
|
||||
panel.querySelectorAll('.color-swatch').forEach((s) => s.classList.remove('color-swatch--active'));
|
||||
sw.classList.add('color-swatch--active');
|
||||
// Farb-Auswahl: Auswahl + ARIA + Keyboard (Roving Tabindex)
|
||||
function selectSwatch(target) {
|
||||
panel.querySelectorAll('.color-swatch').forEach((s) => {
|
||||
s.classList.remove('color-swatch--active');
|
||||
s.setAttribute('aria-checked', 'false');
|
||||
s.setAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
target.classList.add('color-swatch--active');
|
||||
target.setAttribute('aria-checked', 'true');
|
||||
target.setAttribute('tabindex', '0');
|
||||
}
|
||||
panel.querySelectorAll('.color-swatch').forEach((sw) => {
|
||||
if (sw.dataset.color === selectedColor) sw.classList.add('color-swatch--active');
|
||||
if (sw.dataset.color === selectedColor) selectSwatch(sw);
|
||||
sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); });
|
||||
sw.addEventListener('keydown', (e) => {
|
||||
const swatches = [...panel.querySelectorAll('.color-swatch')];
|
||||
const idx = swatches.indexOf(sw);
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = swatches[(idx + 1) % swatches.length];
|
||||
selectSwatch(next); next.focus();
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = swatches[(idx - 1 + swatches.length) % swatches.length];
|
||||
selectSwatch(prev); prev.focus();
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectSwatch(sw);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ganztägig-Toggle
|
||||
@@ -957,11 +991,14 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('calendar.colorLabel')}</label>
|
||||
<div class="color-picker">
|
||||
${EVENT_COLORS.map((c) => `
|
||||
<label class="form-label" id="event-color-label">${t('calendar.colorLabel')}</label>
|
||||
<div class="color-picker" role="radiogroup" aria-labelledby="event-color-label">
|
||||
${EVENT_COLORS.map((c, i) => `
|
||||
<div class="color-swatch" data-color="${c}" style="background-color:${c};"
|
||||
role="radio" tabindex="0" aria-label="${t('calendar.colorLabel', { color: c })}"></div>
|
||||
role="radio"
|
||||
tabindex="${i === 0 ? '0' : '-1'}"
|
||||
aria-checked="false"
|
||||
aria-label="${EVENT_COLOR_NAMES()[c] ?? c}"></div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,8 @@ export async function render(container) {
|
||||
|
||||
usernameGroup.classList.toggle('form-group--error', !username);
|
||||
passwordGroup.classList.toggle('form-group--error', !password);
|
||||
usernameInput.setAttribute('aria-invalid', String(!username));
|
||||
passwordInput.setAttribute('aria-invalid', String(!password));
|
||||
|
||||
if (!username || !password) {
|
||||
if (!username) usernameInput.focus();
|
||||
@@ -117,11 +119,13 @@ export async function render(container) {
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelector('#username').addEventListener('input', () => {
|
||||
form.querySelector('#username').closest('.form-group').classList.remove('form-group--error');
|
||||
form.querySelector('#username').addEventListener('input', (e) => {
|
||||
e.currentTarget.closest('.form-group').classList.remove('form-group--error');
|
||||
e.currentTarget.removeAttribute('aria-invalid');
|
||||
});
|
||||
form.querySelector('#password').addEventListener('input', () => {
|
||||
form.querySelector('#password').closest('.form-group').classList.remove('form-group--error');
|
||||
form.querySelector('#password').addEventListener('input', (e) => {
|
||||
e.currentTarget.closest('.form-group').classList.remove('form-group--error');
|
||||
e.currentTarget.removeAttribute('aria-invalid');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+44
-7
@@ -19,6 +19,17 @@ const NOTE_COLORS = [
|
||||
'#90CAF9', '#CE93D8', '#FFAB91', '#FFFFFF',
|
||||
];
|
||||
|
||||
const NOTE_COLOR_NAMES = () => ({
|
||||
'#FFEB3B': t('notes.colorYellow'),
|
||||
'#FFD54F': t('notes.colorAmber'),
|
||||
'#A5D6A7': t('notes.colorGreen'),
|
||||
'#80DEEA': t('notes.colorTeal'),
|
||||
'#90CAF9': t('notes.colorBlue'),
|
||||
'#CE93D8': t('notes.colorPurple'),
|
||||
'#FFAB91': t('notes.colorOrange'),
|
||||
'#FFFFFF': t('notes.colorWhite'),
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// State
|
||||
// --------------------------------------------------------
|
||||
@@ -368,13 +379,16 @@ function openNoteModal({ mode, note = null }) {
|
||||
style="resize:vertical;">${esc(isEdit ? note.content : '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('notes.colorLabel')}</label>
|
||||
<div class="note-color-picker">
|
||||
<label class="form-label" id="note-color-label">${t('notes.colorLabel')}</label>
|
||||
<div class="note-color-picker" role="radiogroup" aria-labelledby="note-color-label">
|
||||
${NOTE_COLORS.map((c) => `
|
||||
<div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}"
|
||||
data-color="${c}"
|
||||
style="background-color:${c};border:2px solid ${c === '#FFFFFF' ? '#E5E5EA' : c};"
|
||||
role="radio" tabindex="0" aria-label="Farbe ${c}"></div>
|
||||
role="radio"
|
||||
tabindex="${c === selColor ? '0' : '-1'}"
|
||||
aria-checked="${c === selColor ? 'true' : 'false'}"
|
||||
aria-label="${NOTE_COLOR_NAMES()[c] ?? c}"></div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,11 +410,34 @@ function openNoteModal({ mode, note = null }) {
|
||||
content,
|
||||
size: 'md',
|
||||
onSave(panel) {
|
||||
// Farb-Swatch
|
||||
// Farb-Swatch: Auswahl + ARIA + Keyboard (Roving Tabindex)
|
||||
function selectSwatch(target) {
|
||||
panel.querySelectorAll('.note-color-swatch').forEach((s) => {
|
||||
s.classList.remove('note-color-swatch--active');
|
||||
s.setAttribute('aria-checked', 'false');
|
||||
s.setAttribute('tabindex', '-1');
|
||||
});
|
||||
target.classList.add('note-color-swatch--active');
|
||||
target.setAttribute('aria-checked', 'true');
|
||||
target.setAttribute('tabindex', '0');
|
||||
}
|
||||
panel.querySelectorAll('.note-color-swatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => {
|
||||
panel.querySelectorAll('.note-color-swatch').forEach((s) => s.classList.remove('note-color-swatch--active'));
|
||||
sw.classList.add('note-color-swatch--active');
|
||||
sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); });
|
||||
sw.addEventListener('keydown', (e) => {
|
||||
const swatches = [...panel.querySelectorAll('.note-color-swatch')];
|
||||
const idx = swatches.indexOf(sw);
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = swatches[(idx + 1) % swatches.length];
|
||||
selectSwatch(next); next.focus();
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = swatches[(idx - 1 + swatches.length) % swatches.length];
|
||||
selectSwatch(prev); prev.focus();
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectSwatch(sw);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1092,6 +1092,13 @@ function updateOverdueBadge() {
|
||||
}).length;
|
||||
|
||||
document.querySelectorAll('[data-route="/tasks"] .nav-badge').forEach((el) => el.remove());
|
||||
document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => {
|
||||
const baseLabel = t('tasks.title');
|
||||
navItem.setAttribute('aria-label', overdue > 0
|
||||
? t('tasks.navLabelOverdue', { count: overdue })
|
||||
: baseLabel
|
||||
);
|
||||
});
|
||||
if (overdue > 0) {
|
||||
document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => {
|
||||
let anchor = navItem.querySelector('.nav-item__icon-wrap');
|
||||
@@ -1108,6 +1115,7 @@ function updateOverdueBadge() {
|
||||
}
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'nav-badge';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
badge.textContent = String(overdue);
|
||||
anchor.appendChild(badge);
|
||||
});
|
||||
|
||||
@@ -69,8 +69,15 @@ function showBrowserNotification(title, body) {
|
||||
* @param {number} count
|
||||
*/
|
||||
function updateBellBadge(count) {
|
||||
const navLabel = count > 0
|
||||
? t(count === 1 ? 'reminders.pendingBadgeTitle' : 'reminders.pendingBadgeTitlePlural', { count })
|
||||
: t('nav.reminders');
|
||||
document.querySelectorAll('[data-route="/reminders"]').forEach((navItem) => {
|
||||
navItem.setAttribute('aria-label', navLabel);
|
||||
});
|
||||
document.querySelectorAll('.reminder-bell-badge').forEach((badge) => {
|
||||
if (count > 0) {
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
badge.textContent = count > 9 ? '9+' : String(count);
|
||||
badge.hidden = false;
|
||||
} else {
|
||||
|
||||
+15
-2
@@ -267,6 +267,14 @@ async function renderPage(route, previousPath = null) {
|
||||
|
||||
await module.render(pageWrapper, { user: currentUser });
|
||||
|
||||
// Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt)
|
||||
const announcer = document.getElementById('route-announcer');
|
||||
if (announcer) {
|
||||
const pageLabel = navItems().find((n) => n.path === path)?.label ?? path;
|
||||
announcer.textContent = '';
|
||||
setTimeout(() => { announcer.textContent = pageLabel; }, 50);
|
||||
}
|
||||
|
||||
// Erst nach render() + CSS sichtbar machen und Animation starten
|
||||
pageWrapper.style.opacity = '';
|
||||
pageWrapper.classList.add(inClass);
|
||||
@@ -356,7 +364,6 @@ function renderAppShell(container) {
|
||||
const main = document.createElement('main');
|
||||
main.className = 'app-content';
|
||||
main.id = 'main-content';
|
||||
main.setAttribute('aria-live', 'polite');
|
||||
|
||||
const bottomNav = document.createElement('nav');
|
||||
bottomNav.className = 'nav-bottom';
|
||||
@@ -441,7 +448,13 @@ function renderAppShell(container) {
|
||||
toastContainer.id = 'toast-container';
|
||||
toastContainer.setAttribute('aria-live', 'assertive');
|
||||
|
||||
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer);
|
||||
const routeAnnouncer = document.createElement('div');
|
||||
routeAnnouncer.id = 'route-announcer';
|
||||
routeAnnouncer.className = 'sr-only';
|
||||
routeAnnouncer.setAttribute('aria-live', 'polite');
|
||||
routeAnnouncer.setAttribute('aria-atomic', 'true');
|
||||
|
||||
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer, routeAnnouncer);
|
||||
|
||||
// Klick-Handler für alle Nav-Links
|
||||
container.querySelectorAll('[data-route]').forEach((el) => {
|
||||
|
||||
Reference in New Issue
Block a user