From efd4e8c92440f3cd5e5c4fea3d7658dfa6237b81 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 00:38:50 +0200 Subject: [PATCH] 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
(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 --- public/components/modal.js | 1 + public/locales/de.json | 25 ++++++++++++++-- public/pages/calendar.js | 59 +++++++++++++++++++++++++++++++------- public/pages/login.js | 12 +++++--- public/pages/notes.js | 51 +++++++++++++++++++++++++++----- public/pages/tasks.js | 8 ++++++ public/reminders.js | 7 +++++ public/router.js | 17 +++++++++-- 8 files changed, 153 insertions(+), 27 deletions(-) diff --git a/public/components/modal.js b/public/components/modal.js index 7544985..4dcb51f 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -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; } diff --git a/public/locales/de.json b/public/locales/de.json index aba3243..5a47964 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -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", diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 24ddb05..7e86cf4 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -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 }) {
- -
- ${EVENT_COLORS.map((c) => ` + +
+ ${EVENT_COLORS.map((c, i) => ` + role="radio" + tabindex="${i === 0 ? '0' : '-1'}" + aria-checked="false" + aria-label="${EVENT_COLOR_NAMES()[c] ?? c}">
`).join('')}
diff --git a/public/pages/login.js b/public/pages/login.js index 9f72f54..95aef49 100644 --- a/public/pages/login.js +++ b/public/pages/login.js @@ -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'); }); } diff --git a/public/pages/notes.js b/public/pages/notes.js index c19e9ec..da552f2 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -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 : '')}
- -
+ +
${NOTE_COLORS.map((c) => ` + role="radio" + tabindex="${c === selColor ? '0' : '-1'}" + aria-checked="${c === selColor ? 'true' : 'false'}" + aria-label="${NOTE_COLOR_NAMES()[c] ?? c}">
`).join('')}
@@ -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); + } }); }); diff --git a/public/pages/tasks.js b/public/pages/tasks.js index bbd079f..dd8abe7 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -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); }); diff --git a/public/reminders.js b/public/reminders.js index 660491b..72e5c26 100644 --- a/public/reminders.js +++ b/public/reminders.js @@ -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 { diff --git a/public/router.js b/public/router.js index 60fa990..f07aabd 100644 --- a/public/router.js +++ b/public/router.js @@ -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) => {