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:
Ulas Kalayci
2026-04-27 00:38:50 +02:00
parent ca5208341b
commit efd4e8c924
8 changed files with 153 additions and 27 deletions
+48 -11
View File
@@ -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>
+8 -4
View File
@@ -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
View File
@@ -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);
}
});
});
+8
View File
@@ -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);
});