fix(quick-260921-jt4): Kalender-Tageszelle wird echte Schaltflaeche fuer Tage mit Terminen

- Zelle rendert als <button type="button"> NUR wenn hasEvents gilt,
  sonst unveraendert als <div> ohne Handler (D-01) — 42 neue Tab-Stopps
  waeren eine Verschlechterung
- onFocus/onBlur mit demselben Rumpf wie onMouseEnter/onMouseLeave,
  damit die Termin-Einblendung auch per Tastatur erscheint/verschwindet
- cellClass unveraendert uebernommen, nur w-full text-left ergaenzt
  (D-04, kein optischer Unterschied); widgetNoDrag bleibt erhalten
- aria-label nennt Datum und Terminzahl ueber neue Katalogschluessel
  widgets.calendar.dayEventsOne/dayEventsMany (Mehrzahl-Konvention wie
  configMaxEventsOne/Many, D-05)
- Neue Tests: Tag mit Terminen ist <button> und reagiert auf
  Fokus/Weggehen wie auf Maus-Hover; Tag ohne Termine bleibt <div>;
  Einzahl-/Mehrzahl-Beschriftung; echter Tab-Stopp nachgewiesen

Nach diesem Umbau: klick-regeln 5 (nur die vier <img onError> +
Taschenrechner-Rahmen), semantic 0, a11y gesamt 14, errors 0 — Aufgabe
1 des Plans vollstaendig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 14:51:41 +02:00
parent b601141bcf
commit e651c24647
4 changed files with 145 additions and 14 deletions
@@ -26,6 +26,8 @@ vi.mock('next-intl', () => ({
'calendar.upcomingTitle': 'Nächste Termine', 'calendar.upcomingTitle': 'Nächste Termine',
'calendar.tooltipMore': 'Weitere Termine vorhanden', 'calendar.tooltipMore': 'Weitere Termine vorhanden',
'calendar.allDay': 'ganztägig', 'calendar.allDay': 'ganztägig',
'calendar.dayEventsOne': '{date}: 1 Termin',
'calendar.dayEventsMany': '{date}: {count} Termine',
}, },
}; };
const raw = map[ns]?.[key] ?? key; const raw = map[ns]?.[key] ?? key;
@@ -514,4 +516,86 @@ describe('CalendarWidget', () => {
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
}); });
// quick-260921-jt4 (D-01): Tage MIT Terminen sind echte Schaltflaechen und
// per Fokus bedienbar; Tage OHNE Termine bleiben Nicht-Tab-Stopps.
it('Test 11: Ein Tag MIT Terminen ist eine echte <button>-Schaltflaeche und zeigt beim Fokussieren dieselbe Einblendung wie beim Ueberfahren mit der Maus', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-11" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.tagName).toBe('BUTTON');
fireEvent.focus(day20);
const tooltip = screen.getByTestId('calendar-day-tooltip');
expect(tooltip).toHaveTextContent('Team Meeting');
fireEvent.blur(day20);
expect(screen.queryByTestId('calendar-day-tooltip')).toBeNull();
});
it('Test 12: Ein Tag OHNE Termine bleibt eine <div> ohne Handler und ist kein Tab-Stopp', async () => {
mockFetchEvents.mockResolvedValue([]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-12" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.tagName).toBe('DIV');
});
it('Test 13: die Schaltflaeche eines Tages mit Terminen traegt einen Namen, der Datum und Terminzahl nennt', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
ev('Lunch', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-13" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.getAttribute('aria-label')).toContain('20.07');
expect(day20.getAttribute('aria-label')).toContain('2 Termine');
});
it('Test 14: die Schaltflaeche eines Tages mit genau einem Termin nutzt die Einzahl-Beschriftung', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-14" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.getAttribute('aria-label')).toContain('1 Termin');
expect(day20.getAttribute('aria-label')).not.toContain('1 Termine');
});
it('Test 15: der Fokus landet tatsaechlich auf der Tages-Schaltflaeche (echter Tab-Stopp, nicht nur optisch)', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-15" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
day20.focus();
expect(document.activeElement).toBe(day20);
});
}); });
@@ -249,20 +249,8 @@ export function CalendarWidget({ config }: WidgetProps) {
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
return ( const cellContent = (
<div <>
key={day.key}
data-testid="calendar-day"
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={cellClass}
onMouseEnter={(e) =>
hasEvents &&
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
}
onMouseLeave={() => setHover(null)}
>
<span>{day.date.getDate()}</span> <span>{day.date.getDate()}</span>
{hasEvents && ( {hasEvents && (
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht // Wrapper traegt die Positionierung, die einzelnen Kreise nicht
@@ -298,6 +286,61 @@ export function CalendarWidget({ config }: WidgetProps) {
})} })}
</span> </span>
)} )}
</>
);
// Nur Tage MIT Terminen werden zu Tab-Stopps (D-01) — ein
// Widget mit 42 zusaetzlichen Tab-Stopps waere eine
// Verschlechterung. Wer nicht mit der Maus arbeitet, bekam
// die Termin-Einblendung bislang gar nicht zu sehen; die
// Schaltflaeche traegt deshalb zusaetzlich onFocus/onBlur mit
// demselben Rumpf wie onMouseEnter/onMouseLeave.
if (hasEvents) {
const dayLabel = new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(day.date);
const eventCount = day.events.length;
const ariaLabel =
eventCount === 1
? t('calendar.dayEventsOne', { date: dayLabel })
: t('calendar.dayEventsMany', { date: dayLabel, count: eventCount });
return (
<button
key={day.key}
type="button"
data-testid="calendar-day"
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={`${cellClass} w-full text-left`}
aria-label={ariaLabel}
onMouseEnter={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
}
onMouseLeave={() => setHover(null)}
onFocus={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
}
onBlur={() => setHover(null)}
>
{cellContent}
</button>
);
}
return (
<div
key={day.key}
data-testid="calendar-day"
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={cellClass}
>
{cellContent}
</div> </div>
); );
})} })}
+2
View File
@@ -244,6 +244,8 @@
"upcomingTitle": "Nächste Termine", "upcomingTitle": "Nächste Termine",
"tooltipMore": "Weitere Termine vorhanden", "tooltipMore": "Weitere Termine vorhanden",
"allDay": "ganztägig", "allDay": "ganztägig",
"dayEventsOne": "{date}: 1 Termin",
"dayEventsMany": "{date}: {count} Termine",
"configShowMonth": "Monatsansicht anzeigen", "configShowMonth": "Monatsansicht anzeigen",
"configMaxEvents": "Anzahl Termine", "configMaxEvents": "Anzahl Termine",
"configMaxEventsNone": "Ausblenden", "configMaxEventsNone": "Ausblenden",
+2
View File
@@ -244,6 +244,8 @@
"upcomingTitle": "Upcoming events", "upcomingTitle": "Upcoming events",
"tooltipMore": "More events available", "tooltipMore": "More events available",
"allDay": "all day", "allDay": "all day",
"dayEventsOne": "{date}: 1 event",
"dayEventsMany": "{date}: {count} events",
"configShowMonth": "Show month view", "configShowMonth": "Show month view",
"configMaxEvents": "Number of events", "configMaxEvents": "Number of events",
"configMaxEventsNone": "Hide", "configMaxEventsNone": "Hide",